diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 2e79858..8368358 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -986,6 +986,7 @@ when MeshMapper is disconnected. ### Android - Requires permissions: Bluetooth, Location (for BLE scanning) - minSdkVersion: 24 (Flutter's `flutter.minSdkVersion` default; MapLibre GL needs 23+) +- Supports Android Auto. Testing needs Desktop Head Unit, and can not be tested against a physical head unit. - Background location permission for continuous tracking - Uses `flutter_blue_plus` package - URL scheme `meshmapper-auth` (host `callback`) registered on MainActivity via a VIEW/DEFAULT/BROWSABLE intent-filter — the portal sign-in return. The bare `meshmapper://` scheme is deliberately NOT registered: it is a paste-only clipboard format (`docs/CUSTOM_API_ENDPOINT.md`). @@ -1004,6 +1005,7 @@ Key packages used in this project: - `flutter_blue_plus`: Mobile Bluetooth (Android/iOS) - `flutter_web_bluetooth`: Web Bluetooth (Chrome/Edge) +- `flutter_carplay`: Android Auto templates — **vendored & patched**, see below - `geolocator`: GPS/Location - `maplibre_gl`: Map rendering (MapLibre GL vector tiles via OpenFreeMap) — **vendored & patched**, see below - `hive`: Local storage @@ -1041,6 +1043,115 @@ burning. See the `_canAnimateCamera` getter and `_onMapIdle`. **On upgrade:** re-apply the `MESHMAPPER GUARD` blocks to the new plugin version (or drop the override if upstream gains an equivalent guard). +### Vendored `flutter_carplay` (`third_party/flutter_carplay`) + +Consumed from an in-repo copy of the pub.dev `1.6.5` release via `dependency_overrides`, **not** +from pub. Despite the name it is used for its **Android Auto** half only — CarPlay is not shipped. +Three deltas from upstream, each tagged `DELTA` in the vendored source: + +- **DELTA A** (`third_party/flutter_carplay/pubspec.yaml`): the `ios:` plugin platform entry and + the whole `ios/` directory are removed. Upstream would link `SwiftFlutterCarplayPlugin` into the + App Store build — a CarPlay scene-delegate and entitlement surface we neither want nor hold + Apple's CarPlay entitlement for. The Dart `AA*` classes are pure Dart plus a MethodChannel and + still compile on iOS, where `AndroidAutoService.isSupportedPlatform` is false. +- **DELTA B** (`AndroidAutoService.kt`): adds `FAAEngineProvider`, letting the host app supply the + engine. Upstream builds a bare headless `FlutterEngine` whenever the cache is empty, which in + this app is the **wrong** engine — ours owns the USB serial and tile cache channels (see + `MeshMapperEngine.kt`), and two engines means two copies of every plugin fighting over this + plugin's own static template state. +- **DELTA C** (`AndroidAutoService.kt`): `createHostValidator()` no longer returns + `ALLOW_ALL_HOSTS_VALIDATOR` unconditionally. Android documents that as debug-only — it lets any + app on the device bind the service and drive the car surface — so release builds validate against + the car-app library's bundled host allowlist. Debug builds keep the permissive path, which is + what the Desktop Head Unit needs. +- **DELTA D** (`FlutterAndroidAutoPlugin.kt`, `AndroidAutoService.kt`, `Session.kt`, + `lib/aa_models/map/`): adds `MapWithContentTemplate`, its map action strip + (`MapController` + `AAMapAction` + an `onMapActionPressed` event), and a `FAASurfaceProvider` + hook. It also makes `AAPaneTemplate`'s title optional: upstream requires a + non-empty one, but `PaneTemplate.Builder.build()` does not — it validates only rows and + actions — and the title is what draws the header. Upstream + supports six templates, none of which can show a map. The hook lets the host app supply the + `SurfaceCallback`, so MeshMapper's MapLibre renderer lives in the app rather than teaching the + plugin about a map SDK — the same shape as DELTA B. + +`example/`, `previews/` and `test/` are not vendored. + +**On upgrade:** re-apply all three deltas. + +### Android Auto (`lib/services/auto/`) + +The coverage map on the head unit, plus a glance at session state, counters, and one-touch +start/stop. Category is **POI** (`androidx.car.app.category.POI`), because drawing a map is limited +to the navigation, POI and weather categories. + +```bash +adb shell cmd package query-services -a androidx.car.app.CarAppService | grep -A3 meshmapper +``` + +Expect `AndroidAutoService`, `exported=true`, `enabled=true`, and +`Category: "androidx.car.app.category.IOT"`. + +**One-time phone setup.** In the Android Auto settings screen, tap the version header 10× to unlock +developer mode, then from the ⋮ menu: + +- **Unknown sources** — ON. Required. An unpublished car app is *not listed at all* without it. +- **Start head unit server**. + +**After every install of a changed build** — including every `flutter run` — restart the Android +Auto session so it rescans: + +```bash +adb shell am force-stop com.google.android.projection.gearhead +``` + +Not a superstition. Android Auto builds its car-app list by querying `PackageManager` when a +session starts and caches it; `dumpsys package com.google.android.projection.gearhead` shows it +registers only `MY_PACKAGE_REPLACED` (for itself) and **no** `PACKAGE_ADDED`/`PACKAGE_CHANGED` +receiver for other packages. It therefore cannot notice a car app installed after it started. A +freshly installed or updated MeshMapper stays invisible until Android Auto is restarted. + +**Connect:** + +```bash +adb forward tcp:5277 tcp:5277 +$ANDROID_HOME/extras/google/auto/desktop-head-unit # ~/Android/Sdk/extras/google/auto/ +``` + +The phone screen must be unlocked. MeshMapper appears in the DHU launcher and opens to the four-row +pane — briefly a loading spinner first if Dart has not published a template yet, since the plugin's +`MainScreen.onGetTemplate` falls back to a loading `ListTemplate`. + +**`flutter run` and the DHU.** The DHU is **not** a Flutter device: it never appears in +`flutter devices` and is never a `flutter run` target. `flutter run` targets the phone; the car pane +is drawn by that same isolate. + +**Order matters.** Run `flutter run` *first*, then connect the DHU: `MainActivity` creates the +engine, `flutter run` attaches to it, and `FAAEngineProvider` (DELTA B) hands that same engine to +the car service — so **hot reload reaches the pane**. Connect the DHU first and Dart starts headless +in a process `flutter run` never launched; use `flutter attach` to pick it up. + +Debug builds accept any car host, because DELTA C keeps `ALLOW_ALL_HOSTS_VALIDATOR` for debuggable +builds — that is what lets the DHU bind. Release builds validate against the car-app library's +bundled allowlist, which Android Auto is on, so the DHU works there too. + +**The two checks that matter:** + +- **Template quota.** Turn on **Developer settings → Enable debug overlay** and watch the template + counter across a full auto-ping session. If counter updates consume steps, the row layout is + wrong — see the fixed-layout contract above. +- **One engine.** Cold start with `adb shell am force-stop net.meshmapper.app`, then connect. + `[APP] MeshMapper starting...` must appear in logcat **exactly once**. Twice means a second engine + and the `MeshMapperEngine` ownership rule has a hole. + +**Logs:** `adb logcat -s CarApp.H CarApp.H.Dis flutter`, after +`adb shell setprop log.tag.CarApp.H.Dis VERBOSE`. + +**Play submission:** car support needs Google's Android Auto review, declared in Play Console → +*Declare car compatibility* → POI. While a car submission is under review, subsequent app updates +are blocked — so ship it in its own release, not bundled with an urgent fix. Everything +car-specific is one `` block in the manifest plus `lib/services/auto/`; deleting the +service element disables the surface without touching Dart. + ## Development Workflow Requirements ### Debug Logging Convention (MANDATORY) @@ -1236,13 +1347,22 @@ All API endpoints may return maintenance mode: - `lib/services/watch/watch_color.dart` - Wire colour projection shared with the phone map - `lib/services/live_activity/live_activity_service.dart` - ActivityKit bridge: preflight urgency, throttle, dedupe, unavailable backoff - `lib/services/live_activity/live_activity_models.dart` - Live Activity snapshot model and urgency keys -- `lib/services/external_surfaces/external_surface_publisher.dart` - Shared publish pipeline (preflight dedupe, throttle, retry) behind watch, Live Activity, and Siri snapshots +- `lib/services/external_surfaces/external_surface_publisher.dart` - Shared publish pipeline (preflight dedupe, throttle, retry) behind watch, Live Activity, Siri snapshots, and the Android Auto pane - `lib/services/external_surfaces/geo/external_surface_geo_builder.dart` - Ping/repeater/heard geography for external surfaces, with wire caps (was watch_geo_builder) -- `lib/services/external_commands/external_session_commands.dart` - Shared Siri/watch session-command admission and deadline rules +- `lib/services/external_commands/external_session_commands.dart` - Shared Siri/watch/car session-command admission and deadline rules - `lib/services/external_commands/external_command_models.dart` - External command wire model, refusal reasons, and voice copy - `lib/services/app_intents/app_intent_bridge_service.dart` - Siri method channel: command decode, dedupe, snapshot publish - `lib/services/app_intents/siri_snapshot_builder.dart` - App Group snapshot content (recent heard, repeater catalogue, counts) - `lib/services/app_intents/last_companion_connection.dart` - Connect-last-companion admission for the Siri intent +- `lib/services/auto/android_auto_service.dart` - Android Auto surface: connection lifecycle, pane publisher, serialized map sync, action routing +- `lib/services/auto/auto_glance_view.dart` - Pure WatchSnapshot to head-unit-pane projection (fixed 4-row layout) +- `lib/services/auto/car_map_channel.dart` - Dart side of the car map: camera, style and coverage overlay, deduped +- `android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMap.kt` - Native MapLibre map on the car Surface (VirtualDisplay + Presentation) +- `android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMapChannel.kt` - Holds the renderer and bridges it to Dart +- `android/app/src/main/kotlin/net/meshmapper/app/CarMapCoverage.kt` - Tile URL plus finished paint expressions, as Dart describes them +- `android/app/src/main/kotlin/net/meshmapper/app/CarMapTimerBar.kt` - The depleting next-ping bar, animated locally from a deadline +- `android/app/src/main/kotlin/net/meshmapper/app/MeshMapperEngine.kt` - Sole owner of the process FlutterEngine and its app-scoped channels +- `android/app/src/main/kotlin/net/meshmapper/app/MeshMapperApplication.kt` - Installs the engine factory the car service uses - `lib/screens/watch_diagnostics_screen.dart` - Watch transport diagnostics (Settings) - `lib/services/meshcore/packet_validator.dart` - Packet validation and carpeater filtering - `lib/models/noise_floor_session.dart` - Noise floor session data models diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 38066d3..bf900a1 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -78,4 +78,7 @@ dependencies { // OfflineManager for the tile cache MethodChannel handlers. Version must // match maplibre_gl-0.25.0's transitive dep. implementation("org.maplibre.gl:android-sdk:12.3.1") + + // Android for Cars App Library. + implementation("androidx.car.app:app:1.7.0") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cba0748..0cde108 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -17,6 +17,15 @@ + + + + + + + @@ -31,8 +40,8 @@ @@ -73,6 +82,24 @@ android:name="flutterEmbedding" android:value="2" /> + + + + + + + + + + + 0) postInvalidateOnAnimation() + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt index c96604d..c6e10ef 100644 --- a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt +++ b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt @@ -1,5 +1,6 @@ package net.meshmapper.app +import android.content.Context import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel @@ -9,14 +10,14 @@ import org.maplibre.android.offline.OfflineRegionStatus import java.io.File class MainActivity : FlutterActivity() { - private var usbService: MeshMapperUsbService? = null + override fun provideFlutterEngine(context: Context): FlutterEngine = + MeshMapperEngine.obtain(context) + + override fun shouldDestroyEngineWithHost(): Boolean = false override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - usbService = MeshMapperUsbService(this) - usbService!!.configureFlutterEngine(flutterEngine) - // MapLibre tile cache management. Mirrors AppDelegate.swift's iOS // implementation. Called from Dart's TileCacheService by the Offline // Maps screen's Tile Cache card. @@ -96,8 +97,4 @@ class MainActivity : FlutterActivity() { ) } - override fun onDestroy() { - usbService?.dispose() - super.onDestroy() - } } diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperApplication.kt b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperApplication.kt new file mode 100644 index 0000000..09fa37f --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperApplication.kt @@ -0,0 +1,13 @@ +package net.meshmapper.app + +import android.app.Application +import com.oguzhnatly.flutter_android_auto.FAAEngineProvider +import com.oguzhnatly.flutter_android_auto.FAASurfaceProvider + +class MeshMapperApplication : Application() { + override fun onCreate() { + super.onCreate() + FAAEngineProvider.factory = { context -> MeshMapperEngine.obtain(context) } + FAASurfaceProvider.factory = { carContext -> MeshMapperCarMapChannel.create(carContext) } + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMap.kt b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMap.kt new file mode 100644 index 0000000..1f92ef0 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMap.kt @@ -0,0 +1,510 @@ +package net.meshmapper.app + +import android.app.Presentation +import android.content.Context +import android.graphics.Rect +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.os.Bundle +import android.util.Log +import android.view.Display +import android.view.Gravity +import android.view.Surface +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.car.app.CarContext +import androidx.car.app.SurfaceCallback +import androidx.car.app.SurfaceContainer +import org.maplibre.android.MapLibre +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapLibreMapOptions +import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.Style +import org.maplibre.android.style.expressions.Expression +import org.maplibre.android.style.layers.FillLayer +import org.maplibre.android.style.layers.PropertyFactory +import org.maplibre.android.style.layers.SymbolLayer +import org.maplibre.android.style.sources.TileSet +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import org.maplibre.android.style.layers.CircleLayer +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.android.style.sources.VectorSource + +/// Renders MeshMapper's map onto the android auto display +class MeshMapperCarMap(private val carContext: CarContext) : SurfaceCallback { + + private companion object { + const val TAG = "MeshMapperCarMap" + + /// The car reports its own density; this is only the fallback when a + /// SurfaceContainer arrives without one. + const val FALLBACK_DPI = 160 + + const val DEFAULT_ZOOM = 14.0 + + const val COVERAGE_SOURCE_ID = "meshmapper-car-coverage" + const val COVERAGE_LAYER_ID = "meshmapper-car-coverage-layer" + + /// The layer name inside the vector tiles vector_tile.php emits. + const val COVERAGE_SOURCE_LAYER = "coverage" + + const val PINGS_SOURCE_ID = "meshmapper-car-pings" + const val PINGS_LAYER_ID = "meshmapper-car-pings-layer" + + /// Big enough to see at a glance from the driver's seat, small enough + /// that a dense run does not become one solid blob. + const val PING_RADIUS_DP = 12.0f + const val PING_STROKE_DP = 1.5f + const val PING_STROKE_OPACITY = 0.6f + + const val POSITION_SOURCE_ID = "meshmapper-car-position" + const val POSITION_LAYER_ID = "meshmapper-car-position-layer" + const val POSITION_IMAGE_ID = "meshmapper-car-position-icon" + } + + private var virtualDisplay: VirtualDisplay? = null + private var presentation: MapPresentation? = null + + private var pendingCamera: CameraPosition? = null + private var pendingStyleUrl: String? = null + private var appliedStyleUrl: String? = null + private var pendingCoverage: CarMapCoverage? = null + private var pendingPings: String? = null + private var pendingTimer: Triple? = null + private var pendingMarker: Bitmap? = null + private var pendingMarkerFacesHeading = true + private var pendingHeading: Double? = null + + // ---------------------------------------------------------------- surface + + override fun onSurfaceAvailable(container: SurfaceContainer) { + val surface = container.surface + if (surface == null || container.width <= 0 || container.height <= 0) { + Log.w(TAG, "Surface available with nothing to draw on") + return + } + tearDown() + + val dpi = if (container.dpi > 0) container.dpi else FALLBACK_DPI + val display = DisplayManager::class.java.let { + (carContext.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager) + .createVirtualDisplay( + "MeshMapperCarMap", + container.width, + container.height, + dpi, + surface, + // PRESENTATION marks it as a display we intend to show a presentation on. + // OWN_CONTENT_ONLY keeps the phone from being mirrored to the car screen. + DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or + DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY, + ) + } + if (display == null) { + Log.e(TAG, "Could not create a virtual display for the car surface") + return + } + virtualDisplay = display + + presentation = MapPresentation(carContext, display.display).also { + it.show() + pendingMarker?.let { bmp -> + it.setPositionMarker(bmp, pendingMarkerFacesHeading) + } + it.applyPending(pendingStyleUrl, pendingCamera, pendingCoverage, pendingPings) + pendingCamera?.let { cam -> + it.setPosition(cam.target!!.latitude, cam.target!!.longitude, pendingHeading) + } + pendingTimer.let { t -> it.setTimer(t?.first, t?.second, t?.third) } + } + Log.i(TAG, "Car map attached (${container.width}x${container.height} @ ${dpi}dpi)") + } + + override fun onSurfaceDestroyed(container: SurfaceContainer) { + Log.i(TAG, "Car map detached") + tearDown() + } + + /// The region the host guarantees is available. + override fun onVisibleAreaChanged(visibleArea: Rect) { + val map = presentation?.map ?: return + val view = presentation?.mapView ?: return + if (visibleArea.isEmpty) return + + val rightInset = (view.width - visibleArea.right).coerceAtLeast(0) + map.setPadding( + visibleArea.left, + visibleArea.top, + rightInset, + (view.height - visibleArea.bottom).coerceAtLeast(0), + ) + presentation?.setTimerInsets( + right = rightInset, + top = visibleArea.top.coerceAtLeast(0), + bottom = (view.height - visibleArea.bottom).coerceAtLeast(0), + ) + } + + override fun onStableAreaChanged(stableArea: Rect) { + // NO-OP + } + + // --------------------------------------------------------------- gestures + + override fun onScroll(distanceX: Float, distanceY: Float) { + presentation?.map?.let { it.scrollBy(-distanceX, -distanceY) } + } + + override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) { + val map = presentation?.map ?: return + val zoom = map.cameraPosition.zoom + kotlin.math.log2(scaleFactor.toDouble()) + map.moveCamera(CameraUpdateFactory.zoomTo(zoom)) + } + + override fun onFling(velocityX: Float, velocityY: Float) { + // NO-OP + } + + // ------------------------------------------------------------------- data + + /// Called from Dart via [MeshMapperCarMapChannel]. + fun setCamera( + lat: Double, + lon: Double, + bearing: Double?, + heading: Double?, + zoom: Double?, + ) { + val position = CameraPosition.Builder() + .target(LatLng(lat, lon)) + .zoom(zoom ?: DEFAULT_ZOOM) + .apply { bearing?.let { bearing(it) } } + .build() + pendingCamera = position + pendingHeading = heading + presentation?.map?.moveCamera(CameraUpdateFactory.newCameraPosition(position)) + presentation?.setPosition(lat, lon, heading) + } + + fun setPositionMarker(png: ByteArray, facesHeading: Boolean) { + val bitmap = BitmapFactory.decodeByteArray(png, 0, png.size) ?: run { + Log.e(TAG, "Could not decode the position marker") + return + } + pendingMarker = bitmap + pendingMarkerFacesHeading = facesHeading + presentation?.setPositionMarker(bitmap, facesHeading) + } + + /// The style the phone map is currently using + fun setStyle(url: String) { + if (url == appliedStyleUrl) return + pendingStyleUrl = url + presentation?.setStyle(url) { appliedStyleUrl = url } + } + + /// The coverage overlay, the same vector tiles the phone map draws. + fun setCoverage(coverage: CarMapCoverage?) { + if (coverage == pendingCoverage) return + pendingCoverage = coverage + presentation?.setCoverage(coverage) + } + + /// The ping markers, a GeoJSON FeatureCollection built by Dart. + fun setPings(geoJson: String) { + if (geoJson == pendingPings) return + pendingPings = geoJson + presentation?.setPings(geoJson) + } + + /// The next-ping countdown. Null will clear it. + fun setTimer(endsAtMs: Long?, durationMs: Long?, color: Int?) { + pendingTimer = if (endsAtMs != null && durationMs != null) { + Triple(endsAtMs, durationMs, color) + } else { + null + } + presentation?.setTimer(endsAtMs, durationMs, color) + } + + private fun tearDown() { + presentation?.let { + runCatching { it.dismiss() } + } + presentation = null + virtualDisplay?.release() + virtualDisplay = null + } + + /// The window that actually lives on the car display. + private class MapPresentation( + context: Context, + display: Display, + ) : Presentation(context, display) { + + var mapView: MapView? = null + private set + var map: MapLibreMap? = null + private set + + private var styleUrl: String? = null + private var camera: CameraPosition? = null + private var onStyleLoaded: (() -> Unit)? = null + private var coverage: CarMapCoverage? = null + private var pings: String? = null + private var timerBar: CarMapTimerBar? = null + private var markerBitmap: Bitmap? = null + private var markerFacesHeading = true + private var position: Triple? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + MapLibre.getInstance(context) + + val options = MapLibreMapOptions.createFromAttributes(context) + .compassEnabled(false) + .logoEnabled(false) + .attributionEnabled(false) + .rotateGesturesEnabled(false) + .scrollGesturesEnabled(false) + .tiltGesturesEnabled(false) + .zoomGesturesEnabled(false) + + val view = MapView(context, options) + mapView = view + view.onCreate(savedInstanceState) + view.onStart() + view.onResume() + view.getMapAsync { ready -> + map = ready + camera?.let { ready.moveCamera(CameraUpdateFactory.newCameraPosition(it)) } + styleUrl?.let { url -> + val loaded = onStyleLoaded + ready.setStyle(styleBuilder(url)) { style -> + applyOverlays(style) + loaded?.invoke() + } + } + } + + val bar = CarMapTimerBar(context).apply { visibility = View.GONE } + timerBar = bar + + setContentView( + FrameLayout(context).apply { + layoutParams = ViewGroup.LayoutParams(MATCH, MATCH) + addView(view, FrameLayout.LayoutParams(MATCH, MATCH, Gravity.CENTER)) + addView( + bar, + FrameLayout.LayoutParams( + (TIMER_BAR_DP * context.resources.displayMetrics.density).toInt(), + MATCH, + Gravity.END, + ), + ) + }, + ) + } + + fun applyPending( + style: String?, + position: CameraPosition?, + pendingCoverage: CarMapCoverage?, + pendingPings: String?, + ) { + coverage = pendingCoverage + pings = pendingPings + position?.let { + camera = it + map?.moveCamera(CameraUpdateFactory.newCameraPosition(it)) + } + style?.let { setStyle(it, onStyleLoaded) } + } + + fun setStyle(url: String, onLoaded: (() -> Unit)? = null) { + styleUrl = url + onStyleLoaded = onLoaded + map?.setStyle(styleBuilder(url)) { style -> + applyOverlays(style) + onLoaded?.invoke() + } + } + + private fun styleBuilder(style: String): Style.Builder { + val trimmed = style.trimStart() + return if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + Style.Builder().fromJson(style) + } else { + Style.Builder().fromUri(style) + } + } + + fun setCoverage(next: CarMapCoverage?) { + coverage = next + map?.style?.let { applyOverlays(it) } + } + + fun setTimer(endsAtMs: Long?, durationMs: Long?, color: Int?) { + timerBar?.setPhase(endsAtMs, durationMs, color) + } + + /// Keep the bar inside the area the host is not drawing over. + fun setTimerInsets(right: Int, top: Int, bottom: Int) { + val bar = timerBar ?: return + val params = bar.layoutParams as? FrameLayout.LayoutParams ?: return + if (params.rightMargin == right && + params.topMargin == top && + params.bottomMargin == bottom + ) { + return + } + params.rightMargin = right + params.topMargin = top + params.bottomMargin = bottom + bar.layoutParams = params + } + + fun setPositionMarker(bitmap: Bitmap, facesHeading: Boolean) { + markerBitmap = bitmap + markerFacesHeading = facesHeading + map?.style?.let { applyOverlays(it) } + } + + fun setPosition(lat: Double, lon: Double, heading: Double?) { + position = Triple(lat, lon, heading) + val style = map?.style ?: return + val source = style.getSourceAs(POSITION_SOURCE_ID) + if (source == null) { + applyOverlays(style) + return + } + source.setGeoJson(positionGeoJson(lat, lon)) + style.getLayerAs(POSITION_LAYER_ID) + ?.setProperties(PropertyFactory.iconRotate(markerRotation(heading))) + } + + private fun markerRotation(heading: Double?): Float { + if (!markerFacesHeading || heading == null) return 0f + val bearing = map?.cameraPosition?.bearing ?: 0.0 + return ((heading - bearing).toFloat() % 360f + 360f) % 360f + } + + private fun positionGeoJson(lat: Double, lon: Double): String = + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]}}""" + + fun setPings(next: String) { + pings = next + val source = map?.style?.getSourceAs(PINGS_SOURCE_ID) + if (source != null) source.setGeoJson(next) else map?.style?.let { applyOverlays(it) } + } + + private fun applyOverlays(style: Style) { + applyCoverage(style) + applyPings(style) + applyPositionMarker(style) + } + + private fun applyPositionMarker(style: Style) { + style.getLayer(POSITION_LAYER_ID)?.let { style.removeLayer(it) } + style.getSource(POSITION_SOURCE_ID)?.let { style.removeSource(it) } + + val bitmap = markerBitmap ?: return + val here = position ?: return + try { + style.addImage(POSITION_IMAGE_ID, bitmap) + style.addSource( + GeoJsonSource(POSITION_SOURCE_ID, positionGeoJson(here.first, here.second)), + ) + val layer = SymbolLayer(POSITION_LAYER_ID, POSITION_SOURCE_ID) + layer.setProperties( + PropertyFactory.iconImage(POSITION_IMAGE_ID), + PropertyFactory.iconRotate(markerRotation(here.third)), + PropertyFactory.iconAllowOverlap(true), + PropertyFactory.iconIgnorePlacement(true), + ) + style.addLayer(layer) + } catch (e: Exception) { + Log.e(TAG, "Could not apply the position marker", e) + } + } + + /// Ping markers, above the coverage. + private fun applyPings(style: Style) { + style.getLayer(PINGS_LAYER_ID)?.let { style.removeLayer(it) } + style.getSource(PINGS_SOURCE_ID)?.let { style.removeSource(it) } + + val data = pings ?: return + try { + style.addSource(GeoJsonSource(PINGS_SOURCE_ID, data)) + val layer = CircleLayer(PINGS_LAYER_ID, PINGS_SOURCE_ID) + layer.setProperties( + PropertyFactory.circleColor(Expression.get("color")), + PropertyFactory.circleRadius(PING_RADIUS_DP), + PropertyFactory.circleStrokeWidth(PING_STROKE_DP), + PropertyFactory.circleStrokeColor("#000000"), + PropertyFactory.circleStrokeOpacity(PING_STROKE_OPACITY), + ) + style.addLayer(layer) + } catch (e: Exception) { + Log.e(TAG, "Could not apply the ping markers", e) + } + } + + private fun applyCoverage(style: Style) { + style.getLayer(COVERAGE_LAYER_ID)?.let { style.removeLayer(it) } + style.getSource(COVERAGE_SOURCE_ID)?.let { style.removeSource(it) } + + val config = coverage ?: return + try { + style.addSource( + VectorSource( + COVERAGE_SOURCE_ID, + TileSet(TILEJSON_VERSION, config.tileUrl).apply { + minZoom = config.minZoom + maxZoom = config.maxZoom + }, + ), + ) + + val layer = FillLayer(COVERAGE_LAYER_ID, COVERAGE_SOURCE_ID) + .withSourceLayer(COVERAGE_SOURCE_LAYER) + layer.setProperties( + PropertyFactory.fillColor( + Expression.Converter.convert(config.fillColorExpression), + ), + PropertyFactory.fillOutlineColor( + Expression.Converter.convert(config.outlineColorExpression), + ), + PropertyFactory.fillOpacity(config.opacity), + ) + + style.addLayer(layer) + } catch (e: Exception) { + Log.e(TAG, "Could not apply the coverage overlay", e) + } + } + + override fun onStop() { + mapView?.let { + it.onPause() + it.onStop() + it.onDestroy() + } + mapView = null + map = null + super.onStop() + } + + private companion object { + const val MATCH = ViewGroup.LayoutParams.MATCH_PARENT + const val TILEJSON_VERSION = "2.2.0" + const val TIMER_BAR_DP = 8 + } + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMapChannel.kt b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMapChannel.kt new file mode 100644 index 0000000..94f0e2d --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMapChannel.kt @@ -0,0 +1,107 @@ +package net.meshmapper.app + +import androidx.car.app.CarContext +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +/// Dart's channel for the car map. +object MeshMapperCarMapChannel { + private const val CHANNEL = "meshmapper/car_map" + + @Volatile + private var renderer: MeshMapperCarMap? = null + + /// Installed into `FAASurfaceProvider.factory` by [MeshMapperApplication]. + fun create(carContext: CarContext): MeshMapperCarMap = + MeshMapperCarMap(carContext).also { renderer = it } + + fun register(engine: FlutterEngine) { + MethodChannel(engine.dartExecutor.binaryMessenger, CHANNEL) + .setMethodCallHandler { call, result -> + val map = renderer + when (call.method) { + // True if there is a car screen to draw on. + "isAttached" -> result.success(map != null) + + "setCamera" -> { + val lat = call.argument("lat") + val lon = call.argument("lon") + if (lat == null || lon == null) { + result.error("bad_args", "lat and lon are required", null) + } else { + map?.setCamera( + lat, + lon, + call.argument("bearing"), + call.argument("heading"), + call.argument("zoom"), + ) + result.success(map != null) + } + } + + "setStyle" -> { + val url = call.argument("url") + if (url.isNullOrEmpty()) { + result.error("bad_args", "url is required", null) + } else { + map?.setStyle(url) + result.success(map != null) + } + } + + "setCoverage" -> { + val tileUrl = call.argument("tileUrl") + map?.setCoverage( + if (tileUrl.isNullOrEmpty()) { + null + } else { + CarMapCoverage( + tileUrl = tileUrl, + fillColorExpression = + call.argument("fillColor") ?: "", + outlineColorExpression = + call.argument("outlineColor") ?: "", + opacity = + (call.argument("opacity") ?: 0.7).toFloat(), + minZoom = + (call.argument("minZoom") ?: 7.0).toFloat(), + maxZoom = + (call.argument("maxZoom") ?: 14.0).toFloat(), + ) + }, + ) + result.success(map != null) + } + + "setPositionMarker" -> { + val png = call.argument("png") + if (png == null) { + result.error("bad_args", "png is required", null) + } else { + map?.setPositionMarker( + png, + call.argument("facesHeading") ?: true, + ) + result.success(map != null) + } + } + + "setPings" -> { + map?.setPings(call.argument("geoJson") ?: "") + result.success(map != null) + } + + "setTimer" -> { + val endsAt = (call.argument("endsAtMs"))?.toLong() + val duration = (call.argument("durationMs"))?.toLong() + val color = (call.argument("color"))?.toInt() + map?.setTimer(endsAt, duration, color) + result.success(map != null) + } + + else -> result.notImplemented() + } + } + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperEngine.kt b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperEngine.kt new file mode 100644 index 0000000..cbcc019 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperEngine.kt @@ -0,0 +1,30 @@ +package net.meshmapper.app + +import android.content.Context +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.FlutterEngineCache +import io.flutter.embedding.engine.dart.DartExecutor + + +object MeshMapperEngine { + + const val ENGINE_ID = "meshmapper_main" + + private var usbService: MeshMapperUsbService? = null + + @Synchronized + fun obtain(context: Context): FlutterEngine { + FlutterEngineCache.getInstance().get(ENGINE_ID)?.let { return it } + val app = context.applicationContext + val engine = FlutterEngine(app) + registerAppScopedChannels(app, engine) + engine.dartExecutor.executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault()) + FlutterEngineCache.getInstance().put(ENGINE_ID, engine) + return engine + } + + private fun registerAppScopedChannels(app: Context, engine: FlutterEngine) { + usbService = MeshMapperUsbService(app).also { it.configureFlutterEngine(engine) } + MeshMapperCarMapChannel.register(engine) + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt index c2279c4..d99fb03 100644 --- a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt @@ -18,7 +18,6 @@ import android.hardware.usb.UsbManager import android.os.Build import android.os.Handler import android.os.Looper -import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall @@ -28,7 +27,7 @@ import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicLong class MeshMapperUsbService( - private val activity: FlutterActivity, + private val context: Context, ) { private companion object { const val USB_RECIPIENT_INTERFACE = 0x01 @@ -38,7 +37,7 @@ class MeshMapperUsbService( } private val usbManager by lazy { - activity.getSystemService(Context.USB_SERVICE) as UsbManager + context.getSystemService(Context.USB_SERVICE) as UsbManager } private val mainHandler = Handler(Looper.getMainLooper()) private val usbIoExecutor: ExecutorService = Executors.newSingleThreadExecutor() @@ -148,7 +147,7 @@ class MeshMapperUsbService( closeUsbConnection() usbIoExecutor.shutdownNow() try { - activity.unregisterReceiver(permissionReceiver) + context.unregisterReceiver(permissionReceiver) } catch (_: IllegalArgumentException) {} } @@ -158,10 +157,10 @@ class MeshMapperUsbService( addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - activity.registerReceiver(permissionReceiver, filter, Context.RECEIVER_EXPORTED) + context.registerReceiver(permissionReceiver, filter, Context.RECEIVER_EXPORTED) } else { @Suppress("DEPRECATION") - activity.registerReceiver(permissionReceiver, filter) + context.registerReceiver(permissionReceiver, filter) } } @@ -207,9 +206,9 @@ class MeshMapperUsbService( pendingConnectBaudRate = baudRate val permissionIntent = PendingIntent.getBroadcast( - activity, + context, 0, - Intent(USB_PERMISSION_ACTION).setPackage(activity.packageName), + Intent(USB_PERMISSION_ACTION).setPackage(context.packageName), pendingIntentFlags(), ) usbManager.requestPermission(device, permissionIntent) diff --git a/android/app/src/main/res/xml/automotive_app_desc.xml b/android/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 0000000..1a5248e --- /dev/null +++ b/android/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/car_start.png b/assets/car_start.png new file mode 100644 index 0000000..3e41042 Binary files /dev/null and b/assets/car_start.png differ diff --git a/assets/car_stop.png b/assets/car_stop.png new file mode 100644 index 0000000..3a6bcc7 Binary files /dev/null and b/assets/car_stop.png differ diff --git a/lib/main.dart b/lib/main.dart index 98cf00f..badf973 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -67,9 +68,16 @@ void main() async { } debugLog('[APP] Noise floor session adapters registered'); - // Request permissions on startup for mobile platforms + // Deferred rather than awaited. MeshMapperEngine starts this isolate before + // any Activity attaches — an Android Auto head unit can start the process with + // none at all — and permission_handler needs an Activity to raise its dialog + // from. The first frame is the earliest point one is guaranteed. In a headless + // car start no frame is produced, which is exactly when we should not prompt; + // this callback stays pending and fires if the user later opens the app. if (!kIsWeb) { - await _requestPermissions(); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_requestPermissions()); + }); } // Clean up any orphaned background service from a previous session @@ -111,13 +119,20 @@ Future _loadInitialThemeMode() async { Future _requestPermissions() async { debugLog('[APP] Requesting permissions...'); - if (Platform.isIOS) { - // iOS: Use Geolocator for location (permission_handler is unreliable on iOS) - // and trigger Core Bluetooth to prompt for Bluetooth permission - await _requestiOSPermissions(); - } else { - // Android: Use permission_handler - await _requestAndroidPermissions(); + try { + if (Platform.isIOS) { + // iOS: Use Geolocator for location (permission_handler is unreliable on iOS) + // and trigger Core Bluetooth to prompt for Bluetooth permission + await _requestiOSPermissions(); + } else { + // Android: Use permission_handler + await _requestAndroidPermissions(); + } + } catch (e) { + // Never fatal. A permission we could not ask for surfaces later as a + // capability the user can grant from Settings; taking startup down over it + // would cost far more than the missing prompt. + debugLog('[APP] Permission request failed (non-fatal): $e'); } } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 80280de..8e600f7 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -58,7 +58,11 @@ import '../services/external_commands/external_session_commands.dart'; import '../services/external_surfaces/geo/external_surface_geo_builder.dart'; import '../services/live_activity/live_activity_models.dart'; import '../services/live_activity/live_activity_service.dart'; +import '../services/auto/android_auto_service.dart'; +import '../services/auto/car_map_channel.dart'; import '../services/watch/watch_bridge_service.dart'; +import '../widgets/map_widget.dart' + show MapStyleExtension, gpsMarkerFacesHeading; import '../services/watch/watch_models.dart'; import '../services/custom_api_service.dart'; import '../services/portal_account_service.dart'; @@ -166,6 +170,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final LiveActivityService _liveActivityService = LiveActivityService(); final WatchBridgeService _watchBridge = WatchBridgeService(); final AppIntentBridgeService _appIntentBridge = AppIntentBridgeService(); + final AndroidAutoService _androidAuto = AndroidAutoService(); bool _hasEverPairedWatch = false; /// Last position handed to the watch, kept so a dropped GPS fix leaves the @@ -625,6 +630,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_preferences.offlineMode && _apiQueueService.offlinePingCount > 0) { _autoSaveOfflinePings(); } + } else if (state == AppLifecycleState.detached) { + // The engine now outlives MainActivity (see MeshMapperEngine), so the + // Activity going away no longer tears anything down on its own. Swiping + // the app from Recents with no session running used to end the process and + // with it the BLE link; it now leaves the radio connected to a device + // nobody is watching, draining both batteries until something else + // disconnects it. + // + // Deliberately conditional on there being no session: a wardriving run is + // pinned by flutter_background_service's foreground service and is + // *expected* to outlive the UI — that is the whole point of it, and of the + // Android Auto surface that can now drive it with no Activity at all. + if (!_autoPingEnabled) { + debugLog('[APP] Detached with no session — releasing connection'); + unawaited(disconnect()); + } else { + debugLog('[APP] Detached during a session — keeping connection'); + } } } @@ -1455,6 +1478,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // The watch mirrors state even with no session running — otherwise you // could never start one from the wrist. _scheduleWatchSync(immediate: immediate); + _androidAuto.schedule(immediate: immediate); if (!_liveActivityService.isSupportedPlatform) return; _liveActivityService.schedule( _buildLiveActivitySnapshot, @@ -2504,6 +2528,61 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return null; } + /// A tap on the head unit, admitted exactly as a wrist tap or a Siri phrase + /// is. + /// + /// The only car-specific work is resolving the start mode. The strip shows + /// one button and no mode picker — choosing a mode is setup, which the + /// distraction rules forbid while driving — so the command arrives with none + /// and inherits whatever the status row is already promising. That has to + /// happen here rather than inside [resolveExternalSessionTransition]: the + /// shared resolver refuses a modeless start on purpose, and teaching it a + /// third surface's default would put a phone preference inside the rules + /// every surface shares. + ExternalCommandReason? _handleAutoCommand(ExternalSessionCommand command) { + var admitted = command; + if (command.kind == ExternalSessionCommandKind.startSession && + command.mode == null) { + final requested = resolveWatchRequestedStartMode( + requestedMode: null, + isConnected: isConnected, + txAllowed: txAllowed, + ); + final refusal = requested.refusal; + if (refusal != null) return ExternalCommandReason.other(refusal); + admitted = ExternalSessionCommand( + id: command.id, + source: command.source, + kind: command.kind, + issuedAt: command.issuedAt, + expiresAt: command.expiresAt, + mode: resolveLegacyWatchStartMode( + currentMode: _resolvedWatchSessionMode.name, + isConnected: isConnected, + txAllowed: txAllowed, + ), + sessionId: command.sessionId, + ); + } + + final admission = admitExternalSessionCommand(admitted); + if (admission.disposition == ExternalCommandDisposition.refused) { + return admission.reason; + } + if (admission.shouldExecute) { + unawaited(executeExternalSessionCommand(admitted, admission).then( + (completion) { + if (!completion.success) { + _emitWatchFailure( + completion.message?.compactText ?? 'Command failed', + ); + } + }, + )); + } + return null; + } + void _emitWatchFailure(String message) { if (_isDisposed) return; _watchCue = WatchHapticCue( @@ -2513,6 +2592,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { message: message, ); _scheduleWatchSync(immediate: true); + _androidAuto.schedule(immediate: true); } void _handleWatchDiagnosticsChanged() { @@ -3042,6 +3122,28 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _timerListenable.addListener(_handleLiveActivityTimerChange); _timerListenerAttached = true; } + if (_androidAuto.isSupportedPlatform) { + _androidAuto.attach( + snapshotBuilder: _buildWatchSnapshot, + commandHandler: _handleAutoCommand, + onRefusal: (reason) => _emitWatchFailure(reason.compactText), + carMapSettingsBuilder: () => CarMapSettings.from( + styleUrl: + MapStyleExtension.fromString(_preferences.mapStyle).styleUrl, + zoneCode: zoneCode, + tilesEnabled: _preferences.mapTilesEnabled, + gridSize: _preferences.coverageGridSize, + colorVisionType: _preferences.colorVisionType, + opacity: _preferences.coverageOverlayOpacity, + mapAlwaysNorth: _preferences.mapAlwaysNorth, + mapRotationLocked: _preferences.mapRotationLocked, + markerStyle: _preferences.gpsMarkerStyle, + markerFacesHeading: + gpsMarkerFacesHeading(_preferences.gpsMarkerStyle), + nodeName: displayDeviceName, + ), + ); + } if (_watchBridge.isSupportedPlatform) { _watchBridge.diagnostics.addListener(_handleWatchDiagnosticsChanged); _watchBridge.attachCommandHandler( @@ -10667,6 +10769,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _appIntentBridge.dispose(); _watchBridge.diagnostics.removeListener(_handleWatchDiagnosticsChanged); _watchBridge.dispose(); + _androidAuto.dispose(); WidgetsBinding.instance.removeObserver(this); _adapterStateSubscription?.cancel(); _connectionSubscription?.cancel(); diff --git a/lib/services/auto/android_auto_service.dart b/lib/services/auto/android_auto_service.dart new file mode 100644 index 0000000..47d3f10 --- /dev/null +++ b/lib/services/auto/android_auto_service.dart @@ -0,0 +1,318 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_carplay/flutter_carplay.dart'; +import 'package:uuid/uuid.dart'; + +import '../../utils/debug_logger_io.dart'; +import '../../utils/serial_task_gate.dart'; +import '../../widgets/map_widget.dart' show renderGpsMarkerPng; +import '../external_commands/external_command_models.dart'; +import '../external_surfaces/external_surface_color.dart'; +import '../external_surfaces/external_surface_publisher.dart'; +import '../watch/watch_models.dart'; +import 'auto_glance_view.dart'; +import 'car_map_channel.dart'; + +typedef AutoCommandHandler = FutureOr Function( + ExternalSessionCommand command, +); +typedef AutoCommandRefusalHandler = void Function(ExternalCommandReason reason); + +class AndroidAutoService { + AndroidAutoService({ + @visibleForTesting Duration debounceDelay = defaultDebounceDelay, + @visibleForTesting + Duration minimumNonUrgentInterval = defaultMinimumNonUrgentInterval, + @visibleForTesting CarMapChannel? carMap, + @visibleForTesting Future Function(String style)? markerRenderer, + }) : _debounceDelay = debounceDelay, + _carMap = carMap ?? CarMapChannel(), + _renderMarker = markerRenderer ?? renderGpsMarkerPng { + _pane = ExternalSurfacePublisher( + debounceDelay: debounceDelay, + minimumNonUrgentInterval: minimumNonUrgentInterval, + isEnabled: () => isSupportedPlatform && _connected && !_isDisposed, + preflightPolicy: + ExternalSurfacePreflightPolicy.throttleAgainstPublishedUrgency, + payloadBuilder: _buildView, + fingerprintBuilder: (view) => view.fingerprint, + urgencyKeyBuilder: (_) => _viewUnderConsideration?.urgencyKey, + publish: _publishPane, + onQueueError: (error) { + debugLog('[AUTO] Update queue failed: $error'); + }, + restartDebounce: false, + ); + } + + static const Duration defaultDebounceDelay = Duration(milliseconds: 250); + static const Duration defaultMinimumNonUrgentInterval = Duration(seconds: 1); + + static const Duration _markerRenderTimeout = Duration(seconds: 5); + + final Duration _debounceDelay; + final CarMapChannel _carMap; + + final Future Function(String style) _renderMarker; + + late final ExternalSurfacePublisher _pane; + + final SerialTaskGate _mapGate = SerialTaskGate(); + Timer? _mapDebounce; + + AutoGlanceView? _viewUnderConsideration; + + CarMapSettingsBuilder? _carMapSettingsBuilder; + + FlutterAndroidAuto? _auto; + + ExternalSurfaceSnapshotBuilder? _snapshotBuilder; + AutoCommandHandler? _commandHandler; + AutoCommandRefusalHandler? _onRefusal; + + String? _lastMarkerStyle; + + bool _connected = false; + bool _isDisposed = false; + + bool get isSupportedPlatform => + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + + @visibleForTesting + bool get isConnected => _connected; + + void attach({ + required ExternalSurfaceSnapshotBuilder snapshotBuilder, + required AutoCommandHandler commandHandler, + AutoCommandRefusalHandler? onRefusal, + CarMapSettingsBuilder? carMapSettingsBuilder, + }) { + if (!isSupportedPlatform || _isDisposed) return; + + _snapshotBuilder = snapshotBuilder; + _commandHandler = commandHandler; + _onRefusal = onRefusal; + _carMapSettingsBuilder = carMapSettingsBuilder; + + _auto = FlutterAndroidAuto() + ..addListenerOnConnectionChange(_handleConnectionChange); + debugLog('[AUTO] Android Auto surface attached'); + } + + void _handleConnectionChange(ConnectionStatusTypes status) { + if (_isDisposed) return; + debugLog('[AUTO] Connection status: ${status.name}'); + + // `background` means the car is still attached but showing another app. + // Keep publishing: the driver can switch back at any moment and must not + // find a stale pane waiting. + final connected = status == ConnectionStatusTypes.connected || + status == ConnectionStatusTypes.background; + if (connected == _connected) return; + _connected = connected; + + if (!connected) { + _mapDebounce?.cancel(); + _mapDebounce = null; + // Force the next connection to send a fresh root template rather than an + // update against a template history the host no longer has. + _pane.resetPublishedState(); + return; + } + + schedule(immediate: true); + } + + /// Ask for a redraw. Cheap to call on every provider notification. + void schedule({bool immediate = false}) { + if (!isSupportedPlatform || _isDisposed || !_connected) return; + final snapshotBuilder = _snapshotBuilder; + if (snapshotBuilder == null) return; + _pane.schedule( + snapshotBuilder, + preflightKeyBuilder: _noPreflightKey, + immediate: immediate, + ); + _scheduleMapSync(immediate: immediate); + } + + static Object? _noPreflightKey() => null; + + AutoGlanceView _buildView(WatchSnapshot snapshot) { + final view = buildAutoGlanceView( + snapshot, + now: DateTime.now(), + nodeName: _carMapSettingsBuilder?.call().nodeName, + ); + _viewUnderConsideration = view; + return view; + } + + Future _publishPane( + ExternalSurfacePublication publication, + ) async { + try { + await FlutterAndroidAuto.setRootTemplate( + template: AAMapWithContentTemplate( + id: autoGlanceMapTemplateId, + contentTemplate: _buildTemplate(publication.payload), + mapActions: _buildMapActions(publication.payload), + ), + ); + return const ExternalSurfacePublishResult.published(); + } catch (e) { + debugLog('[AUTO] Publish failed: $e'); + return const ExternalSurfacePublishResult.rejected(); + } + } + + void _scheduleMapSync({required bool immediate}) { + if (immediate) { + _mapDebounce?.cancel(); + _mapDebounce = null; + unawaited(_enqueueMapSync()); + return; + } + _mapDebounce ??= Timer(_debounceDelay, () { + _mapDebounce = null; + unawaited(_enqueueMapSync()); + }); + } + + Future _enqueueMapSync() => _mapGate.run(() async { + if (_isDisposed || !_connected) return; + final snapshot = _snapshotBuilder?.call(); + final settings = _carMapSettingsBuilder?.call(); + if (snapshot == null || settings == null) return; + await _syncMap(snapshot, settings); + }).catchError((Object e) { + debugLog('[AUTO] Map sync failed: $e'); + }); + + Future _syncMap(WatchSnapshot snapshot, CarMapSettings settings) async { + final you = snapshot.geo.you; + if (you != null) { + await _carMap.setCamera( + lat: you.lat, + lon: you.lon, + bearing: settings.northUp ? null : you.headingDeg, + heading: you.headingDeg, + ); + } + + await _carMap.setStyle(settings.styleUrl); + await _carMap.setCoverage(settings.coverage); + await _carMap.setPings(snapshot.geo.pings); + + final endsAt = snapshot.core.phaseEndsAt; + final durationMs = snapshot.phaseDurationMs; + await _carMap.setTimer( + endsAt: durationMs == null ? null : endsAt, + durationMs: endsAt == null ? null : durationMs, + argbColor: _argb(snapshot.pingColor), + ); + + await _syncPositionMarker(settings); + } + + Future _syncPositionMarker(CarMapSettings settings) async { + if (settings.markerStyle == _lastMarkerStyle) return; + try { + final png = await _renderMarker(settings.markerStyle) + .timeout(_markerRenderTimeout); + await _carMap.setPositionMarker( + style: settings.markerStyle, + png: png, + facesHeading: settings.markerFacesHeading, + ); + _lastMarkerStyle = settings.markerStyle; + } catch (e) { + + debugLog('[AUTO] Could not render the position marker: $e'); + } + } + + AAPaneTemplate _buildTemplate(AutoGlanceView view) { + return AAPaneTemplate( + id: autoGlanceTemplateId, + title: autoGlanceTitle, + items: [ + for (final row in view.rows) + AAPaneItem(title: row.title, detail: row.detail), + ], + ); + } + + List _buildMapActions(AutoGlanceView view) { + final renderedSessionId = view.sessionId; + final running = renderedSessionId != null; + + return [ + AAMapAction( + id: autoGlanceToggleActionId, + title: running ? 'Stop' : 'Start', + imageUrl: running ? 'assets/car_stop.png' : 'assets/car_start.png', + isPrimary: true, + onPress: () => _send( + ExternalSessionCommand( + id: const Uuid().v4(), + source: ExternalCommandSource.androidAuto, + kind: running + ? ExternalSessionCommandKind.stopSession + : ExternalSessionCommandKind.startSession, + issuedAt: DateTime.now(), + sessionId: running ? renderedSessionId : null, + ), + ), + ), + ]; + } + + void _send(ExternalSessionCommand command) { + final handler = _commandHandler; + if (handler == null || _isDisposed) return; + debugLog('[AUTO] Command: ${command.kind.name}'); + + final refusal = handler(command); + if (refusal is Future) { + unawaited(refusal.then(_handleRefusal)); + return; + } + _handleRefusal(refusal); + } + + void _handleRefusal(ExternalCommandReason? reason) { + if (reason == null || _isDisposed) return; + debugLog('[AUTO] Refused: ${reason.compactText}'); + _onRefusal?.call(reason); + schedule(immediate: true); + } + + void dispose() { + _isDisposed = true; + _mapDebounce?.cancel(); + _mapDebounce = null; + _pane.dispose(); + if (isSupportedPlatform) { + try { + _auto?.closeConnection(); + } catch (_) {} + } + _auto = null; + _snapshotBuilder = null; + _commandHandler = null; + _onRefusal = null; + _carMapSettingsBuilder = null; + _viewUnderConsideration = null; + } +} + +int? _argb(ExternalSurfaceColor? color) { + if (color == null) return null; + int channel(double v) => (v * 255.0).round().clamp(0, 255); + return (0xFF << 24) | + (channel(color.r) << 16) | + (channel(color.g) << 8) | + channel(color.b); +} diff --git a/lib/services/auto/auto_glance_view.dart b/lib/services/auto/auto_glance_view.dart new file mode 100644 index 0000000..bafe28b --- /dev/null +++ b/lib/services/auto/auto_glance_view.dart @@ -0,0 +1,90 @@ +import '../live_activity/live_activity_models.dart'; +import '../watch/watch_models.dart'; + +class AutoGlanceView { + const AutoGlanceView({ + required this.rows, + required this.sessionId, + required this.fingerprint, + required this.urgencyKey, + }); + + final List rows; + final String? sessionId; + final String fingerprint; + final String urgencyKey; +} + +class AutoGlanceRow { + const AutoGlanceRow({required this.title, required this.detail}); + + final String title; + final String detail; +} + +const String autoGlanceFallbackTitle = 'MeshMapper'; +const String autoGlanceTemplateId = 'meshmapper-glance'; +const String autoGlanceMapTemplateId = 'meshmapper-glance-map'; +const String autoGlanceToggleActionId = 'meshmapper-glance-toggle'; +const String autoGlanceTitle = ''; + +AutoGlanceView buildAutoGlanceView( + WatchSnapshot snapshot, { + required DateTime now, + String? nodeName, +}) { + final core = snapshot.core; + final controls = snapshot.controls; + + final title = (nodeName == null || nodeName.isEmpty) + ? autoGlanceFallbackTitle + : nodeName; + + final rows = [ + AutoGlanceRow( + title: title, + detail: [ + _sessionDetail(snapshot, now: now), + _trafficDetail(core), + if (!core.isConnected) 'Disconnected', + if (controls.blockedReason case final reason? + when reason.isNotEmpty && core.isConnected) + reason, + ].join(' · '), + ), + ]; + + final cue = snapshot.cue; + return AutoGlanceView( + rows: rows, + sessionId: controls.isSessionActive ? core.sessionId : null, + fingerprint: rows.map((row) => '${row.title}=${row.detail}').join('|'), + urgencyKey: [ + core.sessionId, + controls.isSessionActive.toString(), + core.phase.wireValue, + core.isConnected.toString(), + if (cue != null && cue.isPresentableAt(now)) cue.id else '', + controls.blockedReason ?? '', + ].join('|'), + ); +} + +String _sessionDetail(WatchSnapshot snapshot, {required DateTime now}) { + final core = snapshot.core; + final cue = snapshot.cue; + final message = cue != null && cue.isPresentableAt(now) ? cue.message : null; + final detail = message ?? core.phaseDetail; + if (detail == null || detail.isEmpty) return core.phaseTitle; + return '${core.phaseTitle} · $detail'; +} + +String _trafficDetail(LiveActivitySnapshot core) { + final parts = [ + 'TX ${core.txCount}', + 'RX ${core.rxCount}', + 'Disc ${core.discoveryCount}', + ]; + if (core.queueSize > 0) parts.add('Queue ${core.queueSize}'); + return parts.join(' · '); +} diff --git a/lib/services/auto/car_map_channel.dart b/lib/services/auto/car_map_channel.dart new file mode 100644 index 0000000..e31c693 --- /dev/null +++ b/lib/services/auto/car_map_channel.dart @@ -0,0 +1,261 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../../utils/coverage_tile_palette.dart'; +import '../external_surfaces/external_surface_color.dart'; +import '../external_surfaces/geo/external_surface_geo_models.dart'; + +import '../../utils/debug_logger_io.dart'; + +typedef CarMapSettingsBuilder = CarMapSettings Function(); + +@immutable +class CarMapSettings { + const CarMapSettings({ + required this.styleUrl, + required this.coverage, + required this.northUp, + required this.markerStyle, + required this.markerFacesHeading, + required this.nodeName, + }); + + + final String styleUrl; + final CarMapCoverage? coverage; + final bool northUp; + final String markerStyle; + final bool markerFacesHeading; + final String? nodeName; + + static CarMapSettings from({ + required String styleUrl, + required String? zoneCode, + required bool tilesEnabled, + required int gridSize, + required String colorVisionType, + required double opacity, + required bool mapAlwaysNorth, + required bool mapRotationLocked, + required String markerStyle, + required bool markerFacesHeading, + String? nodeName, + }) => + CarMapSettings( + styleUrl: styleUrl, + coverage: CarMapCoverage.forZone( + zoneCode: zoneCode, + tilesEnabled: tilesEnabled, + gridSize: gridSize, + colorVisionType: colorVisionType, + opacity: opacity, + ), + northUp: mapAlwaysNorth || mapRotationLocked, + markerStyle: markerStyle, + markerFacesHeading: markerFacesHeading, + nodeName: nodeName, + ); +} + +@immutable +class CarMapCoverage { + const CarMapCoverage({ + required this.tileUrl, + required this.fillColor, + required this.outlineColor, + required this.opacity, + this.minZoom = 7, + this.maxZoom = 14, + }); + + final String tileUrl; + + final String fillColor; + final String outlineColor; + + final double opacity; + final double minZoom; + final double maxZoom; + + static CarMapCoverage? forZone({ + required String? zoneCode, + required int gridSize, + required String colorVisionType, + required double opacity, + bool tilesEnabled = true, + }) { + + if (!tilesEnabled) return null; + if (zoneCode == null || zoneCode.isEmpty || opacity <= 0) return null; + final zone = zoneCode.toLowerCase(); + return CarMapCoverage( + tileUrl: + 'https://$zone.meshmapper.net/vector_tile.php?z={z}&x={x}&y={y}&gsize=$gridSize', + fillColor: jsonEncode( + CoverageTilePalette.fillColorExpression(colorVisionType), + ), + outlineColor: jsonEncode( + CoverageTilePalette.borderColorExpression(colorVisionType), + ), + opacity: opacity, + ); + } + + Map toArguments() => { + 'tileUrl': tileUrl, + 'fillColor': fillColor, + 'outlineColor': outlineColor, + 'opacity': opacity, + 'minZoom': minZoom, + 'maxZoom': maxZoom, + }; + + @override + bool operator ==(Object other) => + other is CarMapCoverage && + other.tileUrl == tileUrl && + other.fillColor == fillColor && + other.outlineColor == outlineColor && + other.opacity == opacity && + other.minZoom == minZoom && + other.maxZoom == maxZoom; + + @override + int get hashCode => Object.hash( + tileUrl, + fillColor, + outlineColor, + opacity, + minZoom, + maxZoom, + ); +} + +class CarMapChannel { + CarMapChannel({@visibleForTesting MethodChannel? channel}) + : _channel = channel ?? const MethodChannel(_channelName); + + static const String _channelName = 'meshmapper/car_map'; + + final MethodChannel _channel; + + double? _lastLat; + double? _lastLon; + double? _lastHeading; + String? _lastMarkerStyle; + CarMapCoverage? _lastCoverage; + bool _hasSentCoverage = false; + String? _lastPings; + String? _lastTimer; + + Future setCamera({ + required double lat, + required double lon, + double? bearing, + double? heading, + double? zoom, + }) async { + // Heading is part of the dedupe: standing still while turning still has to + // move the marker. + if (_sameToFiveDecimals(lat, _lastLat) && + _sameToFiveDecimals(lon, _lastLon) && + heading == _lastHeading) { + return; + } + _lastLat = lat; + _lastLon = lon; + _lastHeading = heading; + await _invoke('setCamera', { + 'lat': lat, + 'lon': lon, + 'bearing': bearing, + 'heading': heading, + 'zoom': zoom, + }); + } + + Future setStyle(String url) async { + if (url.isEmpty) return; + await _invoke('setStyle', {'url': url}); + } + + Future setCoverage(CarMapCoverage? coverage) async { + if (_hasSentCoverage && coverage == _lastCoverage) return; + _hasSentCoverage = true; + _lastCoverage = coverage; + await _invoke('setCoverage', coverage?.toArguments() ?? const {}); + } + + Future setPings(List pings) async { + final geoJson = encodePingsGeoJson(pings); + if (geoJson == _lastPings) return; + _lastPings = geoJson; + await _invoke('setPings', {'geoJson': geoJson}); + } + + Future setTimer({ + DateTime? endsAt, + int? durationMs, + int? argbColor, + }) async { + final key = '${endsAt?.millisecondsSinceEpoch}|$durationMs|$argbColor'; + if (key == _lastTimer) return; + _lastTimer = key; + await _invoke('setTimer', { + 'endsAtMs': endsAt?.millisecondsSinceEpoch, + 'durationMs': durationMs, + 'color': argbColor, + }); + } + + Future setPositionMarker({ + required String style, + required Uint8List png, + required bool facesHeading, + }) async { + if (style == _lastMarkerStyle) return; + _lastMarkerStyle = style; + await _invoke('setPositionMarker', { + 'png': png, + 'facesHeading': facesHeading, + }); + } + + Future _invoke(String method, Map args) async { + try { + await _channel.invokeMethod(method, args); + } on MissingPluginException { + } catch (e) { + debugLog('[AUTO] Car map $method failed: $e'); + } + } + + static bool _sameToFiveDecimals(double value, double? previous) => + previous != null && (value - previous).abs() < 0.00001; +} + +String encodePingsGeoJson(List pings) => jsonEncode({ + 'type': 'FeatureCollection', + 'features': [ + for (final ping in pings) + { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [ping.lon, ping.lat], + }, + 'properties': { + 'color': _hex(ping.color), + 'kind': ping.kind, + }, + }, + ], + }); + +String _hex(ExternalSurfaceColor color) { + String channel(double v) => + (v * 255.0).round().clamp(0, 255).toRadixString(16).padLeft(2, '0'); + return '#${channel(color.r)}${channel(color.g)}${channel(color.b)}'; +} diff --git a/lib/services/external_commands/external_command_models.dart b/lib/services/external_commands/external_command_models.dart index f9e943e..ed84a40 100644 --- a/lib/services/external_commands/external_command_models.dart +++ b/lib/services/external_commands/external_command_models.dart @@ -2,7 +2,8 @@ import 'package:flutter/foundation.dart'; enum ExternalCommandSource { watch, - siri; + siri, + androidAuto; static ExternalCommandSource? fromWire(String value) { for (final source in values) { diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index d25befd..904a03b 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -44,6 +44,23 @@ const _satelliteStyleJson = /// Available in OpenFreeMap glyph sets (Liberty, Bright, Dark, Positron). const _defaultFontStack = ['Noto Sans Regular']; +Future renderGpsMarkerPng(String style) => _renderPainterToPng( + _gpsMarkerPainter(style), + const Size(48, 48), + ); + +bool gpsMarkerFacesHeading(String style) => + style == 'arrow' || style == 'walk' || style == 'chomper'; + +CustomPainter _gpsMarkerPainter(String style) => switch (style) { + 'car' => const _CarMarkerPainter(), + 'bike' => const _BikeMarkerPainter(), + 'boat' => const _BoatMarkerPainter(), + 'walk' => const _WalkMarkerPainter(), + 'chomper' => const _ChomperMarkerPainter(), + _ => const _ArrowPainter(), + }; + /// Image-name constants for the marker bitmaps registered via /// `controller.addImage()` and referenced by `SymbolOptions.iconImage`. /// diff --git a/pubspec.lock b/pubspec.lock index 86d9a5f..0bb6f3a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -37,10 +37,10 @@ packages: dependency: transitive description: name: app_links_platform_interface - sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" url: "https://pub.dev" source: hosted - version: "2.0.4" + version: "2.0.2" app_links_web: dependency: transitive description: @@ -165,10 +165,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -430,6 +430,13 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + flutter_carplay: + dependency: "direct main" + description: + path: "third_party/flutter_carplay" + relative: true + source: path + version: "1.6.5" flutter_launcher_icons: dependency: "direct dev" description: @@ -526,6 +533,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -819,26 +834,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -903,6 +918,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -1248,10 +1271,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.7" timezone: dependency: transitive description: @@ -1344,10 +1367,34 @@ packages: dependency: "direct main" description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "1.3.0" vector_math: dependency: transitive description: @@ -1445,5 +1492,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.12.0 <4.0.0" - flutter: ">=3.44.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 4e38cac..4f01c38 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: 'none' version: 1.0.0+1 environment: - sdk: '>=3.3.0 <4.0.0' + sdk: '>=3.6.0 <4.0.0' dependencies: flutter: @@ -44,6 +44,7 @@ dependencies: app_links: ^6.3.0 # Keychain / Android Keystore storage for the portal app token flutter_secure_storage: ^9.2.0 + flutter_carplay: ^1.6.5 dev_dependencies: flutter_test: @@ -62,9 +63,27 @@ dev_dependencies: # an uncaught C++ std::domain_error (SIGABRT) when it is degenerate/zero-sized. # That throw cannot be caught from Dart, so the fix must live in the plugin. # Re-apply the guard when bumping maplibre_gl. See DEVELOPMENT.md. +# Vendored, patched copy of flutter_carplay 1.6.5, used for its Android Auto +# half only. Three deltas from the upstream pub.dev release, all marked "DELTA" +# in the vendored source: +# A. The `ios:` plugin platform entry and the ios/ directory are removed. +# MeshMapper ships on the App Store; upstream would link +# SwiftFlutterCarplayPlugin into Runner for a CarPlay surface we neither +# want nor hold Apple's entitlement for. +# B. AndroidAutoService gains FAAEngineProvider, so the app can supply the +# engine. Upstream builds a bare headless one when the cache is empty, +# which in this app is the wrong engine — ours owns the USB serial and +# tile cache channels (see MeshMapperEngine.kt). +# C. createHostValidator no longer returns ALLOW_ALL_HOSTS_VALIDATOR in +# release builds; that is debug-only per Android's docs and lets any app +# on the device drive the car surface. +# example/, previews/ and test/ are not vendored. Re-apply all three deltas when +# bumping flutter_carplay. See DEVELOPMENT.md. dependency_overrides: maplibre_gl: path: third_party/maplibre_gl + flutter_carplay: + path: third_party/flutter_carplay flutter_launcher_icons: android: true @@ -82,3 +101,9 @@ flutter: - assets/wardrive.png - assets/transmitted_packet.mp3 - assets/received_packet.mp3 + # Android Auto map action strip icons. The strip is icon-only — + # ACTIONS_CONSTRAINTS_MAP leaves maxCustomTitles at 0 — so these are not + # decoration, they are the buttons. White on transparent so the host tints + # them to its own theme. + - assets/car_start.png + - assets/car_stop.png diff --git a/test/services/auto/android_auto_service_test.dart b/test/services/auto/android_auto_service_test.dart new file mode 100644 index 0000000..8a7310e --- /dev/null +++ b/test/services/auto/android_auto_service_test.dart @@ -0,0 +1,423 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_carplay/controllers/android_auto_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/auto/android_auto_service.dart'; +import 'package:mesh_mapper/services/auto/auto_glance_view.dart'; +import 'package:mesh_mapper/services/auto/car_map_channel.dart'; +import 'package:mesh_mapper/services/external_surfaces/geo/external_surface_geo_models.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; +import 'package:mesh_mapper/services/watch/watch_models.dart'; + +const String _methodChannelName = 'com.oguzhnatly.flutter_android_auto'; +const String _carMapChannelName = 'meshmapper/car_map_ordering_test'; +const String _eventChannelName = 'com.oguzhnatly.flutter_android_auto/event'; + +final DateTime _now = DateTime.fromMillisecondsSinceEpoch(1760000000000); + +WatchSnapshot _snapshot({ + bool withPosition = false, + int txCount = 0, + bool isSessionActive = true, + String sessionId = 'session-1', + LiveActivityPhase phase = LiveActivityPhase.listening, + String phaseTitle = 'Listening', +}) => + WatchSnapshot( + core: LiveActivitySnapshot( + sessionId: sessionId, + mode: 'Active', + phase: phase, + phaseTitle: phaseTitle, + phaseDetail: 'Waiting for echoes', + isConnected: true, + zoneCode: 'SEA', + txCount: txCount, + rxCount: 0, + discoveryCount: 0, + traceCount: 0, + queueSize: 0, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: true, + updatedAt: _now, + ), + geo: ExternalSurfaceGeo( + pings: const [], + repeaters: const [], + heard: const [], + linkedRepeaterIds: const [], + you: withPosition + ? ExternalSurfacePosition(lat: 47.6, lon: -122.3, fixedAt: _now) + : null, + ), + controls: WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: isSessionActive, + ), + updatedAt: _now, + ); + +/// Records what the surface sends native, so a test can assert on the sequence +/// rather than on the plugin's internals. +class _NativeRecorder { + final List calls = []; + final List> templates = >[]; + + void install() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel(_methodChannelName), + (MethodCall call) async { + calls.add(call.method); + final args = call.arguments; + if (args is Map && args['template'] is Map) { + templates.add(args['template'] as Map); + } + return true; + }, + ); + // The event channel's listen/cancel arrive as method calls on a channel of + // the same name. + messenger.setMockMethodCallHandler( + const MethodChannel(_eventChannelName), + (MethodCall call) async => null, + ); + } + + void clear() { + calls.clear(); + templates.clear(); + } +} + +Future _emit(Object event) async { + const codec = StandardMethodCodec(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _eventChannelName, + codec.encodeSuccessEnvelope(event), + (_) {}, + ); +} + +Future _connect() => _emit({ + 'type': 'onAndroidAutoConnectionChange', + 'data': {'status': 'connected'}, + }); + +Future _disconnect() => _emit({ + 'type': 'onAndroidAutoConnectionChange', + 'data': {'status': 'disconnected'}, + }); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _NativeRecorder native; + late AndroidAutoService service; + late WatchSnapshot current; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + // Static across instances in the plugin; a leftover entry would make an + // update look like it matched when it did not. + FlutterAndroidAutoController.templateHistory.clear(); + native = _NativeRecorder()..install(); + current = _snapshot(); + }); + + tearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + }); + + AndroidAutoService build({ + Duration debounce = Duration.zero, + Duration floor = const Duration(seconds: 1), + AutoCommandHandler? onCommand, + AutoCommandRefusalHandler? onRefusal, + CarMapSettingsBuilder? settings, + Future Function(String)? markerRenderer, + }) { + service = AndroidAutoService( + debounceDelay: debounce, + minimumNonUrgentInterval: floor, + carMap: CarMapChannel(channel: const MethodChannel(_carMapChannelName)), + markerRenderer: markerRenderer, + )..attach( + snapshotBuilder: () => current, + commandHandler: onCommand ?? (_) => null, + onRefusal: onRefusal, + carMapSettingsBuilder: settings, + ); + return service; + } + + /// The ordering this pins is load-bearing and easy to lose. + /// + /// `_publish` runs `_syncMap` *above* the fingerprint gate. A settings change + /// alters no pane text, so its fingerprint is unchanged and the gate returns + /// early — if the map sync sat below it, every settings change would be + /// swallowed and the head unit would quietly keep the old style, palette and + /// coverage. That failure is invisible in tests and only shows up in a car. + group('settings reach the map even when the pane does not change', () { + late List carMapCalls; + + setUp(() { + carMapCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(_carMapChannelName), + (call) async { + carMapCalls.add(call.method); + return true; + }, + ); + }); + + /// Rendering the marker rasterizes a CustomPainter, which needs the + /// engine's raster thread — and a backgrounded phone, which is exactly the + /// car case, may not be pumping it. When that render ran before the camera, + /// one stall left the camera unset and the head unit showed a whole-world + /// view over null island. Nothing cosmetic may precede the thing that + /// decides where the map looks. + test('the camera is sent even when the marker cannot be rendered', + () async { + build( + settings: () => CarMapSettings.from( + styleUrl: 'https://tiles.openfreemap.org/styles/dark', + zoneCode: 'sea', + tilesEnabled: true, + gridSize: 300, + colorVisionType: 'none', + opacity: 0.7, + mapAlwaysNorth: true, + mapRotationLocked: false, + markerStyle: 'arrow', + markerFacesHeading: true, + ), + markerRenderer: (_) => Future.error( + StateError('no rasterizer'), + ), + ); + current = _snapshot(withPosition: true); + await _connect(); + await pumpEventQueue(); + + expect(carMapCalls, contains('setCamera')); + }); + + test('a publish with an unchanged fingerprint still syncs the map', + () async { + build( + settings: () => CarMapSettings.from( + styleUrl: 'https://tiles.openfreemap.org/styles/dark', + zoneCode: 'sea', + tilesEnabled: true, + gridSize: 300, + colorVisionType: 'none', + opacity: 0.7, + mapAlwaysNorth: true, + mapRotationLocked: false, + markerStyle: 'arrow', + markerFacesHeading: true, + ), + ); + await _connect(); + await pumpEventQueue(); + + // Everything the pane renders is identical, so the template is skipped. + native.clear(); + carMapCalls.clear(); + service.schedule(immediate: true); + await pumpEventQueue(); + + expect(native.calls, isEmpty, reason: 'the pane really is unchanged'); + expect(carMapCalls, contains('setStyle'), + reason: 'but the map must still be told'); + }); + + /// The pane's update floor is quota insurance for template refreshes. The + /// map costs no quota, and a driver's camera must not inherit a text + /// throttle: at a one-second floor the map would lag a moving vehicle by up + /// to a second, and at the thirty here it would look frozen. This is why + /// the two run on separate paths rather than sharing one publisher. + test('the pane floor does not throttle the map', () async { + build( + floor: const Duration(seconds: 30), + settings: () => CarMapSettings.from( + styleUrl: 'https://tiles.openfreemap.org/styles/dark', + zoneCode: 'sea', + tilesEnabled: true, + gridSize: 300, + colorVisionType: 'none', + opacity: 0.7, + mapAlwaysNorth: true, + mapRotationLocked: false, + markerStyle: 'arrow', + markerFacesHeading: true, + ), + ); + await _connect(); + await pumpEventQueue(); + native.clear(); + carMapCalls.clear(); + + // A counter change: the pane's text differs, but nothing in it is urgent, + // so the floor holds the template back. + current = _snapshot(txCount: 1); + service.schedule(immediate: true); + await pumpEventQueue(); + + expect(native.calls, isEmpty, reason: 'the pane is inside its floor'); + expect(carMapCalls, contains('setStyle'), + reason: 'the map is not, and never was'); + }); + }); + + test('publishes nothing until the car connects', () async { + build().schedule(immediate: true); + await pumpEventQueue(); + expect(native.calls, isEmpty); + }); + + test('always re-sets the root template, never updatePaneTemplate', () async { + build(); + await _connect(); + await pumpEventQueue(); + expect(native.calls, ['setRootTemplate']); + + native.clear(); + current = + _snapshot(phase: LiveActivityPhase.sending, phaseTitle: 'Sending'); + service.schedule(immediate: true); + await pumpEventQueue(); + // updatePaneTemplate rebuilds the addressed element *as* the root, which + // would swap the map out for a bare pane. + expect(native.calls, ['setRootTemplate']); + }); + + test('the root is a map template wrapping the pane', () async { + build(); + await _connect(); + await pumpEventQueue(); + + final root = native.templates.single; + expect(root['contentRuntimeType'], 'FAAPaneTemplate'); + final content = root['contentTemplate'] as Map; + expect(content['_elementId'], autoGlanceTemplateId); + // An empty title means no header row. The panel's width is the host's, so + // the header was the only part of it we could give back to the map — and a + // stray title would quietly take it again. + expect(content['title'], isEmpty); + expect((content['items'] as List), hasLength(1), + reason: 'one status row, not a menu — the map needs the space'); + expect((content['actions'] as List), isEmpty, + reason: 'the controls are in the map action strip'); + expect((root['mapActions'] as List), hasLength(1), + reason: 'one toggle button, not a permanent Start and Stop pair'); + }); + + test('every update reuses the root template id', () async { + build(); + await _connect(); + await pumpEventQueue(); + current = + _snapshot(phase: LiveActivityPhase.sending, phaseTitle: 'Sending'); + service.schedule(immediate: true); + await pumpEventQueue(); + + // A fresh uuid per rebuild would leave the plugin matching nothing in its + // template history, and the surface would silently desync. + expect(native.templates, hasLength(2)); + for (final template in native.templates) { + expect(template['_elementId'], autoGlanceMapTemplateId); + expect( + (template['contentTemplate'] as Map)['_elementId'], + autoGlanceTemplateId, + ); + } + }); + + test('identical content is not sent twice', () async { + build(); + await _connect(); + await pumpEventQueue(); + native.clear(); + + service.schedule(immediate: true); + service.schedule(immediate: true); + await pumpEventQueue(); + expect(native.calls, isEmpty); + }); + + test('counter churn waits out the floor', () async { + build(floor: const Duration(milliseconds: 200)); + await _connect(); + await pumpEventQueue(); + native.clear(); + + current = _snapshot(txCount: 1); + service.schedule(immediate: true); + await pumpEventQueue(); + expect(native.calls, isEmpty, reason: 'inside the floor'); + + await Future.delayed(const Duration(milliseconds: 260)); + await pumpEventQueue(); + expect(native.calls, ['setRootTemplate'], + reason: 'deferred, not dropped — the last pings of a run still draw'); + }); + + test('an urgent change bypasses the floor', () async { + build(floor: const Duration(seconds: 30)); + await _connect(); + await pumpEventQueue(); + native.clear(); + + current = + _snapshot(phase: LiveActivityPhase.sending, phaseTitle: 'Sending'); + service.schedule(immediate: true); + await pumpEventQueue(); + expect(native.calls, ['setRootTemplate']); + }); + + test('a reconnect sends a fresh root template', () async { + build(); + await _connect(); + await pumpEventQueue(); + native.clear(); + + await _disconnect(); + await _connect(); + await pumpEventQueue(); + expect(native.calls, ['setRootTemplate'], + reason: + 'the host no longer holds the template history we updated into'); + }); + + test('a disconnected surface publishes nothing', () async { + build(); + await _connect(); + await pumpEventQueue(); + await _disconnect(); + native.clear(); + + current = + _snapshot(phase: LiveActivityPhase.sending, phaseTitle: 'Sending'); + service.schedule(immediate: true); + await pumpEventQueue(); + expect(native.calls, isEmpty); + }); + + test('is inert off Android', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + build(); + expect(service.isSupportedPlatform, isFalse); + await _connect(); + await pumpEventQueue(); + expect(native.calls, isEmpty); + }); +} diff --git a/test/services/auto/auto_command_routing_test.dart b/test/services/auto/auto_command_routing_test.dart new file mode 100644 index 0000000..7558800 --- /dev/null +++ b/test/services/auto/auto_command_routing_test.dart @@ -0,0 +1,255 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_carplay/controllers/android_auto_controller.dart'; +import 'package:flutter_carplay/flutter_carplay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/auto/android_auto_service.dart'; +import 'package:mesh_mapper/services/auto/auto_glance_view.dart'; +import 'package:mesh_mapper/services/external_commands/external_command_models.dart'; +import 'package:mesh_mapper/services/external_commands/external_session_commands.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; +import 'package:mesh_mapper/services/watch/watch_models.dart'; + +const String _methodChannelName = 'com.oguzhnatly.flutter_android_auto'; +const String _eventChannelName = 'com.oguzhnatly.flutter_android_auto/event'; + +final DateTime _now = DateTime.fromMillisecondsSinceEpoch(1760000000000); + +WatchSnapshot _snapshot({ + required String sessionId, + required bool isSessionActive, +}) => + WatchSnapshot( + core: LiveActivitySnapshot( + sessionId: sessionId, + mode: 'Active', + phase: LiveActivityPhase.listening, + phaseTitle: isSessionActive ? 'Listening' : 'Ready', + phaseDetail: isSessionActive ? 'Waiting for echoes' : null, + isConnected: true, + zoneCode: 'SEA', + txCount: 0, + rxCount: 0, + discoveryCount: 0, + traceCount: 0, + queueSize: 0, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: true, + updatedAt: _now, + ), + geo: const WatchGeo( + pings: [], + repeaters: [], + heard: [], + linkedRepeaterIds: [], + ), + controls: WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: isSessionActive, + ), + updatedAt: _now, + ); + +/// The last template the surface sent native, decoded back into the action +/// closures the plugin would invoke on a tap. +/// +/// The controls live in the map action strip, not the pane: the pane was filling +/// half the head unit, and the strip is the vertical bar the host draws down the +/// right edge of the map. +List _renderedActions() => + (FlutterAndroidAutoController.templateHistory.last + as AAMapWithContentTemplate) + .mapActions; + +void _tap(String actionId) { + _renderedActions() + .firstWhere((action) => action.uniqueId == actionId) + .onPress!(); +} + +Future _emitConnected() async { + const codec = StandardMethodCodec(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _eventChannelName, + codec.encodeSuccessEnvelope({ + 'type': 'onAndroidAutoConnectionChange', + 'data': {'status': 'connected'}, + }), + (_) {}, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AndroidAutoService service; + late List received; + late List refusals; + late WatchSnapshot current; + ExternalCommandReason? nextRefusal; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + FlutterAndroidAutoController.templateHistory.clear(); + received = []; + refusals = []; + nextRefusal = null; + current = _snapshot(sessionId: 'session-1', isSessionActive: true); + + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel(_methodChannelName), + (MethodCall call) async => true, + ); + messenger.setMockMethodCallHandler( + const MethodChannel(_eventChannelName), + (MethodCall call) async => null, + ); + + service = AndroidAutoService( + debounceDelay: Duration.zero, + minimumNonUrgentInterval: Duration.zero, + )..attach( + snapshotBuilder: () => current, + commandHandler: (command) { + received.add(command); + return nextRefusal; + }, + onRefusal: refusals.add, + ); + }); + + tearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + }); + + test('the strip carries one toggle action', () async { + await _emitConnected(); + await pumpEventQueue(); + + // One button, not two: Start and Stop are mutually exclusive, so showing + // both meant one of them was always the wrong thing to press. + final actions = _renderedActions(); + expect(actions, hasLength(1)); + expect(actions.single.uniqueId, autoGlanceToggleActionId); + expect(actions.single.isPrimary, isTrue); + // The map strip is icon-only — ACTIONS_CONSTRAINTS_MAP leaves + // maxCustomTitles at zero, so an action without an icon is rejected and the + // whole template goes with it. + for (final action in actions) { + expect(action.imageUrl, isNotEmpty); + } + }); + + test('Start sends no mode, deferring to the mode the screen promised', + () async { + // Idle, so the toggle points at Start. + current = _snapshot(sessionId: 'session-1', isSessionActive: false); + await _emitConnected(); + await pumpEventQueue(); + + _tap(autoGlanceToggleActionId); + expect(received, hasLength(1)); + expect(received.single.kind, ExternalSessionCommandKind.startSession); + expect(received.single.source, ExternalCommandSource.androidAuto); + // Null defers to the phone, which resolves it to the same value the + // status row renders. Naming a mode here would let the button start + // something other than what the driver was shown. + expect(received.single.mode, isNull); + }); + + test('Stop names the session the screen was rendering', () async { + await _emitConnected(); + await pumpEventQueue(); + + _tap(autoGlanceToggleActionId); + expect(received.single.kind, ExternalSessionCommandKind.stopSession); + expect(received.single.sessionId, 'session-1'); + }); + + test('with no session the button starts one instead', () async { + current = _snapshot(sessionId: 'session-1', isSessionActive: false); + await _emitConnected(); + await pumpEventQueue(); + + // With no session the button points the other way: it starts one. + _tap(autoGlanceToggleActionId); + expect(received.single.kind, ExternalSessionCommandKind.startSession); + }); + + test('a Stop from a stale screen is refused rather than stopping the new run', + () async { + await _emitConnected(); + await pumpEventQueue(); + + // The driver is looking at a screen for session-1. It ends and session-2 + // begins; a fresh snapshot is published. + final staleActions = _renderedActions(); + current = _snapshot(sessionId: 'session-2', isSessionActive: true); + service.schedule(immediate: true); + await pumpEventQueue(); + + // They tap the Stop they were looking at. + staleActions + .firstWhere((action) => action.uniqueId == autoGlanceToggleActionId) + .onPress!(); + + final command = received.single; + expect(command.sessionId, 'session-1'); + + // Which is exactly what the provider's guard needs to refuse it — and it + // is the shared guard, reached with the very command the strip built. + final admission = resolveExternalSessionTransition( + command: command, + isSessionActive: true, + isSessionStarting: false, + currentMode: 'passive', + currentSessionId: 'session-2', + currentModeLabel: 'Passive', + ); + expect(admission.shouldExecute, isFalse); + expect(admission.reason, ExternalCommandReason.sessionAlreadyEnded); + }); + + test('a refusal reaches the refusal handler', () async { + await _emitConnected(); + await pumpEventQueue(); + + nextRefusal = ExternalCommandReason.notConnected; + _tap(autoGlanceToggleActionId); + await pumpEventQueue(); + expect(refusals, [ExternalCommandReason.notConnected]); + }); + + test('an admitted command reports no refusal', () async { + await _emitConnected(); + await pumpEventQueue(); + + _tap(autoGlanceToggleActionId); + await pumpEventQueue(); + expect(refusals, isEmpty); + }); + + test('an asynchronous refusal still reaches the handler', () async { + service.dispose(); + service = AndroidAutoService( + debounceDelay: Duration.zero, + minimumNonUrgentInterval: Duration.zero, + )..attach( + snapshotBuilder: () => current, + commandHandler: (command) async => ExternalCommandReason.couldNotStart, + onRefusal: refusals.add, + ); + await _emitConnected(); + await pumpEventQueue(); + + _tap(autoGlanceToggleActionId); + await pumpEventQueue(); + expect(refusals, [ExternalCommandReason.couldNotStart]); + }); +} diff --git a/test/services/auto/auto_glance_view_test.dart b/test/services/auto/auto_glance_view_test.dart new file mode 100644 index 0000000..edc22e8 --- /dev/null +++ b/test/services/auto/auto_glance_view_test.dart @@ -0,0 +1,275 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/auto/auto_glance_view.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; +import 'package:mesh_mapper/services/watch/watch_models.dart'; + +final DateTime _now = DateTime.fromMillisecondsSinceEpoch(1760000000000); + +WatchSnapshot _snapshot({ + WatchHapticCue? cue, + String phaseTitle = 'Listening', + String? phaseDetail = 'Waiting for echoes', + LiveActivityPhase phase = LiveActivityPhase.listening, + bool isConnected = true, + bool isSessionActive = true, + String sessionId = 'session-1', + String mode = 'Active', + String? zoneCode = 'SEA', + String? blockedReason, + int txCount = 3, + int rxCount = 2, + int discoveryCount = 1, + int queueSize = 4, + WatchGeo? geo, +}) => + WatchSnapshot( + core: LiveActivitySnapshot( + sessionId: sessionId, + mode: mode, + phase: phase, + phaseTitle: phaseTitle, + phaseDetail: phaseDetail, + isConnected: isConnected, + zoneCode: zoneCode, + txCount: txCount, + rxCount: rxCount, + discoveryCount: discoveryCount, + traceCount: 0, + queueSize: queueSize, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: true, + updatedAt: _now, + ), + geo: geo ?? + const WatchGeo( + pings: [], + repeaters: [], + heard: [], + linkedRepeaterIds: [], + ), + controls: WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: isSessionActive, + blockedReason: blockedReason, + ), + cue: cue, + updatedAt: _now, + ); + +void main() { + group('layout contract', () { + // These are not cosmetic assertions. Android Auto allows a pane update to be + // a free "refresh" only while the row count and row titles are unchanged; + // anything else counts against a five-template-per-task quota, and a driver + // on a long wardrive would watch the surface stop updating. Changing what + // these pin means re-checking the quota on the Desktop Head Unit's debug + // overlay, not just re-recording the expectation. + // + // One row, not four: the content panel is what crowds the map, and the + // controls moved to the map action strip. + test('always renders exactly one row', () { + for (final snapshot in [ + _snapshot(), + _snapshot(isSessionActive: false, phase: LiveActivityPhase.idle), + _snapshot(isConnected: false, zoneCode: null, phaseDetail: null), + _snapshot(blockedReason: 'Not connected', queueSize: 0), + ]) { + final view = buildAutoGlanceView(snapshot, now: _now); + expect(view.rows, hasLength(1)); + for (final row in view.rows) { + expect(row.detail, isNotEmpty, + reason: + 'a blank row still occupies the slot but reads as broken'); + } + } + }); + }); + + group('the row title', () { + // The node name, so the driver can see which radio the numbers belong to. + // It changes only on connect, disconnect or a rename — a handful of + // templates over a session, not one per publish, so the refresh quota that + // the fixed row count protects still holds. + test('is the connected node', () { + final view = + buildAutoGlanceView(_snapshot(), now: _now, nodeName: 'Alders0n'); + expect(view.rows.single.title, 'Alders0n'); + }); + + test('falls back when nothing is connected', () { + expect( + buildAutoGlanceView(_snapshot(), now: _now).rows.single.title, + autoGlanceFallbackTitle, + ); + expect( + buildAutoGlanceView(_snapshot(), now: _now, nodeName: '') + .rows + .single + .title, + autoGlanceFallbackTitle, + ); + }); + }); + + group('the status line', () { + String lineOf(WatchSnapshot snapshot) => + buildAutoGlanceView(snapshot, now: _now).rows.single.detail; + + test('carries the phase and the counters', () { + expect( + lineOf(_snapshot( + txCount: 12, + rxCount: 7, + discoveryCount: 3, + queueSize: 0, + )), + 'Listening · Waiting for echoes · TX 12 · RX 7 · Disc 3', + ); + }); + + test('a queue backlog is worth the space, an empty queue is not', () { + expect(lineOf(_snapshot(queueSize: 5)), contains('Queue 5')); + expect(lineOf(_snapshot(queueSize: 0)), isNot(contains('Queue'))); + }); + + test('falls back to the phase title alone with no detail', () { + expect( + lineOf(_snapshot(phaseTitle: 'Ready', phaseDetail: null)), + startsWith('Ready · TX'), + ); + }); + + test('a live cue outranks the phase detail', () { + expect( + lineOf(_snapshot( + cue: WatchHapticCue( + id: 'cue-1', + kind: 'failure', + issuedAt: _now, + message: 'Not connected', + ), + )), + startsWith('Listening · Not connected'), + ); + }); + + test('a cue stops outranking it once it is no longer readable', () { + expect( + lineOf(_snapshot( + cue: WatchHapticCue( + id: 'cue-1', + kind: 'failure', + issuedAt: _now.subtract(WatchWire.cueReadableFor), + message: 'Not connected', + ), + )), + startsWith('Listening · Waiting for echoes'), + ); + }); + + // A disconnected radio is the one state where the phase alone can mislead, + // so it earns the extra words. + test('says so when the radio is disconnected', () { + expect(lineOf(_snapshot(isConnected: false)), contains('Disconnected')); + expect(lineOf(_snapshot()), isNot(contains('Disconnected'))); + }); + + test('surfaces why the next tap would be refused', () { + expect( + lineOf(_snapshot(blockedReason: 'Outside zone')), + endsWith('Outside zone'), + ); + }); + }); + + group('sessionId capture', () { + test('is the running session', () { + final view = buildAutoGlanceView(_snapshot(sessionId: 'abc'), now: _now); + expect(view.sessionId, 'abc'); + }); + + // Carrying an id past the end of its session would let a Stop name a run + // that has already stopped, which is exactly what the admission guard exists + // to catch — so do not hand it one. + test('is null when no session is running', () { + final view = buildAutoGlanceView( + _snapshot(isSessionActive: false), + now: _now, + ); + expect(view.sessionId, isNull); + }); + }); + + group('urgencyKey', () { + String keyOf(WatchSnapshot s) => + buildAutoGlanceView(s, now: _now).urgencyKey; + + // Counters move constantly during a session. If they were urgent, the floor + // that keeps the pane inside its template quota would never apply. + test('does not change on counters alone', () { + expect( + keyOf(_snapshot(txCount: 99, rxCount: 98, discoveryCount: 97)), + keyOf(_snapshot()), + ); + }); + + test('changes when the session changes', () { + expect(keyOf(_snapshot(sessionId: 'other')), isNot(keyOf(_snapshot()))); + }); + + test('changes when the session stops', () { + expect( + keyOf(_snapshot(isSessionActive: false)), + isNot(keyOf(_snapshot())), + ); + }); + + test('changes when the phase changes', () { + expect( + keyOf(_snapshot(phase: LiveActivityPhase.sending)), + isNot(keyOf(_snapshot())), + ); + }); + + test('changes when the radio connects or drops', () { + expect(keyOf(_snapshot(isConnected: false)), isNot(keyOf(_snapshot()))); + }); + + test('changes when a new cue arrives', () { + final cued = _snapshot( + cue: WatchHapticCue( + id: 'cue-1', + kind: 'failure', + issuedAt: _now, + message: 'Could not start', + ), + ); + expect(keyOf(cued), isNot(keyOf(_snapshot()))); + }); + + test('changes when the blocked reason changes', () { + expect( + keyOf(_snapshot(blockedReason: 'Not connected')), + isNot(keyOf(_snapshot())), + ); + }); + }); + + group('fingerprint', () { + test('is identical for identical content', () { + expect( + buildAutoGlanceView(_snapshot(), now: _now).fingerprint, + buildAutoGlanceView(_snapshot(), now: _now).fingerprint, + ); + }); + + test('changes when a counter changes', () { + expect( + buildAutoGlanceView(_snapshot(txCount: 4), now: _now).fingerprint, + isNot(buildAutoGlanceView(_snapshot(), now: _now).fingerprint), + ); + }); + }); +} diff --git a/test/services/auto/car_map_channel_test.dart b/test/services/auto/car_map_channel_test.dart new file mode 100644 index 0000000..de41ac1 --- /dev/null +++ b/test/services/auto/car_map_channel_test.dart @@ -0,0 +1,376 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/auto/car_map_channel.dart'; +import 'package:mesh_mapper/widgets/map_widget.dart' show gpsMarkerFacesHeading; +import 'package:mesh_mapper/services/watch/watch_models.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('meshmapper/car_map_test'); + late List calls; + + setUp(() { + calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return true; + }); + }); + + CarMapChannel build() => CarMapChannel(channel: channel); + + test('sends the camera through to native', () async { + await build().setCamera(lat: 47.6, lon: -122.3, bearing: 90); + expect(calls.single.method, 'setCamera'); + expect(calls.single.arguments['lat'], 47.6); + expect(calls.single.arguments['lon'], -122.3); + expect(calls.single.arguments['bearing'], 90); + }); + + // GPS ticks at 1–2 Hz and jitters below a metre. Forwarding every one would + // be a platform round-trip per tick for a camera move no driver can see. + test('sub-metre jitter does not reach native', () async { + final map = build(); + await map.setCamera(lat: 47.600000, lon: -122.300000); + await map.setCamera(lat: 47.600001, lon: -122.300001); + expect(calls, hasLength(1)); + }); + + test('real movement does reach native', () async { + final map = build(); + await map.setCamera(lat: 47.6000, lon: -122.3000); + await map.setCamera(lat: 47.6010, lon: -122.3000); + expect(calls, hasLength(2)); + }); + + test('a style change is sent', () async { + final map = build(); + await map.setStyle('https://tiles.openfreemap.org/styles/dark'); + await map.setStyle('https://tiles.openfreemap.org/styles/bright'); + expect(calls, hasLength(2)); + }); + + test('an empty style is ignored', () async { + await build().setStyle(''); + expect(calls, isEmpty); + }); + + group('settings', () { + CarMapSettings settings({ + bool northUp = true, + bool rotationLocked = false, + String style = 'https://tiles.openfreemap.org/styles/dark', + String marker = 'arrow', + }) => + CarMapSettings.from( + styleUrl: style, + zoneCode: 'sea', + tilesEnabled: true, + gridSize: 300, + colorVisionType: 'none', + opacity: 0.7, + mapAlwaysNorth: northUp, + mapRotationLocked: rotationLocked, + markerStyle: marker, + markerFacesHeading: gpsMarkerFacesHeading(marker), + ); + + // mapAlwaysNorth defaults true, so getting this wrong rotates the car map + // for most people who never asked for it. + test('north-up is honoured', () { + expect(settings(northUp: true).northUp, isTrue); + expect(settings(northUp: false).northUp, isFalse); + }); + + test('the marker style and its rotation rule ride along', () { + expect(settings(marker: 'arrow').markerStyle, 'arrow'); + expect(settings(marker: 'arrow').markerFacesHeading, isTrue); + // Car, bike and boat stay upright on a rotated map. + expect(settings(marker: 'car').markerFacesHeading, isFalse); + }); + + test('a rotation lock also means north-up', () { + expect(settings(northUp: false, rotationLocked: true).northUp, isTrue); + }); + + // The satellite style is an inline style document, not a URL. Anything that + // trims, normalises or url-encodes it on the way through breaks it. + test('an inline style document passes through unmangled', () { + const doc = '{"version":8,"sources":{}}'; + expect(settings(style: doc).styleUrl, doc); + }); + }); + + group('pings', () { + WatchPing ping(String id, double lat, double lon, WatchColor color) => + WatchPing( + id: id, + lat: lat, + lon: lon, + kind: 'tx', + color: color, + at: DateTime.fromMillisecondsSinceEpoch(1760000000000), + ); + + test('one feature per ping, with its resolved colour', () { + final json = jsonDecode(encodePingsGeoJson([ + ping('a', 47.6, -122.3, const WatchColor(1, 0, 0)), + ping('b', 47.7, -122.4, const WatchColor(0, 1, 0)), + ])) as Map; + + expect(json['type'], 'FeatureCollection'); + final features = json['features'] as List; + expect(features, hasLength(2)); + expect((features.first as Map)['properties'], { + 'color': '#ff0000', + 'kind': 'tx', + }); + expect((features.last as Map)['properties'], contains('color')); + }); + + // GeoJSON is lon,lat. Getting it backwards produces a map that looks + // plausible and is wrong. + test('coordinates are lon,lat', () { + final json = jsonDecode( + encodePingsGeoJson( + [ping('a', 47.6, -122.3, const WatchColor(1, 1, 1))]), + ) as Map; + final geometry = + ((json['features'] as List).single as Map)['geometry'] as Map; + expect(geometry['coordinates'], [-122.3, 47.6]); + }); + + test('no pings is an empty collection, not a missing one', () { + final json = jsonDecode(encodePingsGeoJson([])) as Map; + expect(json['features'], isEmpty); + }); + + test('is sent, then deduped', () async { + final map = build(); + final one = [ping('a', 47.6, -122.3, const WatchColor(1, 0, 0))]; + await map.setPings(one); + await map.setPings([...one]); + expect(calls, hasLength(1)); + expect(calls.single.method, 'setPings'); + }); + + test('a new ping is sent', () async { + final map = build(); + await map.setPings([ping('a', 47.6, -122.3, const WatchColor(1, 0, 0))]); + await map.setPings([ + ping('a', 47.6, -122.3, const WatchColor(1, 0, 0)), + ping('b', 47.7, -122.4, const WatchColor(0, 1, 0)), + ]); + expect(calls, hasLength(2)); + }); + }); + + group('timer', () { + final deadline = DateTime.fromMillisecondsSinceEpoch(1760000030000); + + test('deadline and duration cross, nothing per-second', () async { + await build() + .setTimer(endsAt: deadline, durationMs: 30000, argbColor: 0xFFFF0000); + expect(calls.single.method, 'setTimer'); + expect(calls.single.arguments['endsAtMs'], 1760000030000); + expect(calls.single.arguments['durationMs'], 30000); + }); + + // A phase with no deadline must clear the bar, not leave a stuck one. + test('a deadline-less phase clears it', () async { + await build().setTimer(); + expect(calls.single.arguments['endsAtMs'], isNull); + expect(calls.single.arguments['durationMs'], isNull); + }); + + test('an unchanged phase is not re-sent', () async { + final map = build(); + await map.setTimer(endsAt: deadline, durationMs: 30000); + await map.setTimer(endsAt: deadline, durationMs: 30000); + expect(calls, hasLength(1)); + }); + + test('a new cycle is sent', () async { + final map = build(); + await map.setTimer(endsAt: deadline, durationMs: 30000); + await map.setTimer( + endsAt: deadline.add(const Duration(seconds: 30)), + durationMs: 30000, + ); + expect(calls, hasLength(2)); + }); + }); + + // The style is deliberately not deduped on this side: a load is asynchronous + // and can fail, and caching what we sent would remember a failure as a + // success and never retry. Native dedupes on what actually loaded. + test('the style is re-sent every time', () async { + final map = build(); + await map.setStyle('https://tiles.openfreemap.org/styles/dark'); + await map.setStyle('https://tiles.openfreemap.org/styles/dark'); + expect(calls, hasLength(2)); + }); + + group('the position marker', () { + final png = Uint8List.fromList(const [1, 2, 3, 4]); + + test('is sent once, then deduped on the style name', () async { + final map = build(); + await map.setPositionMarker(style: 'arrow', png: png, facesHeading: true); + await map.setPositionMarker(style: 'arrow', png: png, facesHeading: true); + expect(calls, hasLength(1)); + expect(calls.single.method, 'setPositionMarker'); + expect(calls.single.arguments['facesHeading'], isTrue); + }); + + test('a style change re-sends it', () async { + final map = build(); + await map.setPositionMarker(style: 'arrow', png: png, facesHeading: true); + await map.setPositionMarker(style: 'car', png: png, facesHeading: false); + expect(calls, hasLength(2)); + expect(calls.last.arguments['facesHeading'], isFalse); + }); + }); + + group('camera bearing versus marker heading', () { + // The subtle one. In north-up mode the camera bearing is deliberately null + // so the map does not turn, but the marker still has to point along the + // direction of travel. Collapsing these into one field leaves the arrow + // stuck pointing north on the setting most people have on by default. + test('heading survives while bearing is nulled', () async { + await build().setCamera( + lat: 47.6, + lon: -122.3, + bearing: null, + heading: 90, + ); + expect(calls.single.arguments['bearing'], isNull); + expect(calls.single.arguments['heading'], 90); + }); + + test('turning on the spot still moves the marker', () async { + final map = build(); + await map.setCamera(lat: 47.6, lon: -122.3, heading: 0); + await map.setCamera(lat: 47.6, lon: -122.3, heading: 90); + expect(calls, hasLength(2), + reason: 'position deduping must not swallow a heading change'); + }); + }); + + // The channel exists only on Android, and only once the engine has registered + // it. Every other platform must be a silent no-op, not a crash on a GPS tick. + group('coverage', () { + CarMapCoverage? forZone({ + String? zone = 'sea', + int gridSize = 300, + String cvd = 'none', + double opacity = 0.7, + bool tilesEnabled = true, + }) => + CarMapCoverage.forZone( + zoneCode: zone, + gridSize: gridSize, + colorVisionType: cvd, + opacity: opacity, + tilesEnabled: tilesEnabled, + ); + + // The user turns tiles off to stop spending a tethered connection. + // Honouring that only on the phone spends it anyway, where they cannot see. + test('tiles switched off means nothing to draw', () { + expect(forZone(tilesEnabled: false), isNull); + }); + + test('the tile url carries the zone and grid size', () { + final coverage = forZone(zone: 'SEA', gridSize: 500)!; + expect( + coverage.tileUrl, + 'https://sea.meshmapper.net/vector_tile.php' + '?z={z}&x={x}&y={y}&gsize=500', + ); + }); + + // Zone and grid size live in the URL, so either changing is a different + // source — not a restyled one. The dedupe below has to see that. + test('a grid change is a different overlay', () { + expect(forZone(gridSize: 300), isNot(forZone(gridSize: 500))); + }); + + test('a zone change is a different overlay', () { + expect(forZone(zone: 'sea'), isNot(forZone(zone: 'pdx'))); + }); + + test('there is nothing to draw before a zone is known', () { + expect(forZone(zone: null), isNull); + expect(forZone(zone: ''), isNull); + }); + + test('a fully transparent overlay is nothing to draw', () { + expect(forZone(opacity: 0), isNull); + }); + + // Dart owns the palette; native must never hold a second copy that drifts. + // What crosses the channel is a finished MapLibre expression. + test('the palette crosses as a MapLibre match expression', () { + final coverage = forZone()!; + final fill = jsonDecode(coverage.fillColor) as List; + expect(fill.first, 'match'); + expect(fill[1], ['get', 'st']); + expect(fill, contains('#1e7e34'), reason: 'st 1 green, from the palette'); + expect( + jsonDecode(coverage.outlineColor) as List, + contains('#14522d'), + reason: 'st 1 border, from the palette', + ); + }); + + test('a colour-vision mode changes the expression', () { + expect(forZone(cvd: 'none')!.fillColor, + isNot(forZone(cvd: 'protanopia')!.fillColor)); + }); + + test('is sent to native', () async { + await build().setCoverage(forZone()); + expect(calls.single.method, 'setCoverage'); + expect(calls.single.arguments['tileUrl'], contains('sea.meshmapper.net')); + expect(calls.single.arguments['opacity'], 0.7); + }); + + // Re-applying rebuilds the layer, which restarts every tile request — + // expensive on a tether and visible as a flash of empty map. + test('an unchanged overlay is not re-sent', () async { + final map = build(); + await map.setCoverage(forZone()); + await map.setCoverage(forZone()); + expect(calls, hasLength(1)); + }); + + test('a changed overlay is re-sent', () async { + final map = build(); + await map.setCoverage(forZone(gridSize: 300)); + await map.setCoverage(forZone(gridSize: 500)); + expect(calls, hasLength(2)); + }); + + test('clearing is sent once, then deduped', () async { + final map = build(); + await map.setCoverage(null); + await map.setCoverage(null); + expect(calls, hasLength(1)); + expect(calls.single.arguments, isEmpty); + }); + }); + + test('a missing native side is not fatal', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + await expectLater( + build().setCamera(lat: 47.6, lon: -122.3), + completes, + ); + }); +} diff --git a/third_party/flutter_carplay/CHANGELOG.md b/third_party/flutter_carplay/CHANGELOG.md new file mode 100644 index 0000000..afb05e6 --- /dev/null +++ b/third_party/flutter_carplay/CHANGELOG.md @@ -0,0 +1,202 @@ +## 1.6.5 - 2026-08-21 + +- Fix Android Auto list template startup by validating selectable lists before they reach the host (#120) (ty @JulianBissekkou) +- Complete Android Auto method-channel calls when coroutine handlers throw (#133, #134) (ty @JulianBissekkou) +- Restore the README star history chart (#135) + +## 1.6.4 - 2026-07-06 + +- Move the Android Auto message template docs into the Android Auto API usage section (#129) (ty @EArminjon) + +## 1.6.3 - 2026-06-22 + +- Fix Android Auto example release launches by allowing root template refresh before the car screen is attached (#127, #128) (ty @deandreamatias) +- Keep the Android Auto selectable list demo in its own template so it follows host validation rules (#128) + +## 1.6.2 - 2026-06-16 + +- Support setting `CPSearchTemplate` as the CarPlay root template. +- Add a Search Template row to the example app and a README preview image. +- Document CarPlay entitlement options while keeping parking as the example default for Point of Interest support. + +## 1.6.1 - 2026-06-15 + +- Fix Swift Package Manager builds by keeping `FCPSearchTemplate.swift` inside the SwiftPM source path (#123, #124) (ty @Gabriellsp) +- Reject unsupported `CPSearchTemplate` root templates before CarPlay receives an invalid root template. + +## 1.6.0 - 2026-06-13 + +- Add Swift Package Manager support for iOS package consumers (#111) (ty @justinbeatz) +- Keep `FCPImageTint.swift` in the SwiftPM source path and align CocoaPods source files with the new package layout (#111) (ty @justinbeatz) + +## 1.5.1 + +- Fix CarPlay tab templates when the same list item ID appears in multiple tabs (#121) (ty @Gabriellsp) + +## 1.5.0 + +- Add Android Auto alert, grid, and tab bar templates (#102) (ty @Gabriellsp) +- Add Android Auto modal alert presentation and dismissal APIs (#102) (ty @Gabriellsp) +- Add Android Auto list loading messages and empty view titles (#102) (ty @Gabriellsp) + +## 1.4.0 + +- Add `CPSearchTemplate` support for CarPlay search flows (#96) (ty @sINFdorako) +- Add Android Auto list item, section, and template IDs, including selection and toggle handling (#105) (ty @JulianBissekkou) +- Add Flutter asset SVG support across CarPlay and Android Auto image fields (#112) (ty @JulianBissekkou) +- Add Android Auto `AAMessageTemplate` and `AALongMessageTemplate` support with update APIs (#116) (ty @JulianBissekkou) +- Add Android Auto `AAPaneTemplate` support with update APIs (#119) (ty @JulianBissekkou) +- Clarify the difference between Android Auto and Android Automotive OS in the README (#115) (ty @JulianBissekkou) + +## 1.3.3 + +- Expose `onPop` callbacks on pushed CarPlay templates (#106) (ty @sINFdorako) +- Add Android Auto list item, section, and template IDs (#103) (ty @JulianBissekkou) + +## 1.3.2 + +- Avoid iOS crash around image management (#101) (ty @EArminjon) + +## 1.3.1 + +- Fix crash on iOS 18 and earlier when `CPListImageRowItem` is truncated by the host and fewer image slots are available than requested (#98) (ty @EArminjon) +- Add update methods for CPInformationTemplate items and actions (#97) (ty @sINFdorako) +- Document `CPListImageRowItem` support and `CPInformationTemplate` update methods in the README +- Update the security policy supported versions to include `1.3.x` + +## 1.3.0 + +- Implement CPListImageRowItem with full support for cardElements, condensedElements, elements, gridElements, and imageGridElements (#94) (ty @EArminjon) +- Support individual update for CPListImageRowItem and CPListImageRowItemElement +- Expose tabTitle, systemIcon, and showsTabBadges on CPTabBarTemplate +- Rename enums and classes to be 1:1 with Apple documentation (breaking change) +- Rework UUID logic to allow custom elementId on all models +- Expose convenient update methods on CPListItem and other templates +- Improve image loading error handling with proper error callbacks +- Fix app crash when images are empty string +- Add missing GridButton handler as optional (not required) +- Update uuid dependency to 4.5.3 + +## 1.2.11 + +- Fix `List` is not a subtype of `Iterable` in `updateTemplates` (#92) (ty @EArminjon) +- Fix failed cast on `updateCPListItem` when item is not in root template (#91) (ty @RedC4ke) + +## 1.2.10 + +- Add `@objc(FlutterCarPlaySceneDelegate)` annotation for iOS 26 compatibility (#87) + +This enables apps to reference the delegate class as `flutter_carplay.FlutterCarPlaySceneDelegate` in their scene manifest configuration. Required for runtime class discovery via `NSClassFromString`. Thanks @APIUM! + +## 1.2.9 + +- Add missing `@available(iOS 14.0, *)` annotations to `makeUIImage` and `loadUIImageAsync` (#84) + +These functions reference `SwiftFlutterCarplayPlugin` which requires iOS 14.0+, so Swift requires the availability annotation to propagate. This was missing since v1.2.5. + +## 1.2.8 + +- Fix build failure on Xcode without iOS 26 SDK (follow up to #84) + +The v1.2.7 fix using `#if compiler(>=6.0)` didn't work because Swift 6.0 shipped with Xcode 16 (iOS 18), before iOS 26. Now uses dynamic selector invocation to avoid compile time symbol lookup for `updateImage`. + +## 1.2.7 + +- Fix build failure on Xcode versions without iOS 26 SDK (#84) + +The `CPGridButton.updateImage()` API introduced in 1.2.5 is only available in iOS 26+. This caused compile errors on older Xcode versions since `#available` only handles runtime checks, not compile time SDK availability. iOS 26 specific code is now wrapped in `#if compiler(>=6.0)` to ensure older toolchains skip it entirely. + +- Fix type mismatch in `updateTabBarTemplates` that prevented compilation + +## 1.2.6 + +- Fix compatibility with Dart's `--obfuscate` flag by using explicit type checks instead of `runtimeType.toString()` (fixes #28) +- Add security policy (SECURITY.md) + +## 1.2.5 + +- Fix main thread image loading crash for CPListItem and CPGridButton in https://github.com/oguzhnatly/flutter_carplay/pull/79 (ty @EArminjon) + +This fixes a crash caused by creating UIImage on background threads. Network images are now loaded asynchronously using URLSession, and placeholder images are shown until the actual image loads. For iOS 26+, CPGridButton uses the new `updateImage()` API for async updates. + +## 1.2.4 + +- Fix file URI percent encoding for album art paths with spaces in https://github.com/oguzhnatly/flutter_carplay/pull/82 (ty @APIUM) +- Add `sectionIndexEnabled` option to CPListTemplate for hiding section index letters in https://github.com/oguzhnatly/flutter_carplay/pull/83 (ty @APIUM) + +## 1.2.3 + +- Update tab bar template to support mixed template types in https://github.com/oguzhnatly/flutter_carplay/pull/81 + +This enhances the tab bar template by enabling support for multiple template types (not just list templates) as tab bar children. Supported template types: CPListTemplate, CPPointOfInterestTemplate, CPGridTemplate, CPInformationTemplate (ty @shihabkandil). + +## 1.2.2 + +**Issues:** +Calling `updateTemplates` or `updateSections` updates the layout correctly when the CarPlay is already active, but fail to do when CarPlay not yet started. Using `updateTemplates` or `updateSections` doesn’t refresh ListItem's handler properly, causing missing callbacks. This results in items showing a loading indicator for several seconds because the end event never fires. + +It's been updated by @EArminjon in https://github.com/oguzhnatly/flutter_carplay/pull/77 + +**Fixes :** +- Ensure `updateTemplate` and `updateSections` correctly refresh all relevant data and update the `final _super.handler`. +- Reformatted the code. +- Reuse existing `CPTemplate` instances instead of recreating them. +- Renamed variables to improve clarity. + +## 1.2.1 + +- Update tabBar templates in https://github.com/oguzhnatly/flutter_carplay/pull/71 + +This allow updating a tabBar without removing entire stack. This is useful to add, update or remove tabs. + +**Bug fixes :** +- Ensure that updateSections only recreate necessary entries. +- Ensure that updateSections take and memorise new entries (by using List.from). + +## 1.2.0 + +- Add early support for Android Auto under a new controller `FlutterAndroidAuto`. Not all features are supported yet, see the README for more details. ([#71](https://github.com/oguzhnatly/flutter_carplay/pull/71)) (ty @EArminjon). +- History have been reworked to ensure that all templates are well ordered, presents and synchronized. +- Rename some classes to avoid confusion between Android Auto and CarPlay (breaking change) + - `CPConnectionStatusTypes` -> `ConnectionStatusTypes` + - `CPEnumUtils` -> `EnumUtils` + +## 1.1.3 + +- Documentation and packaging improvements +- Automated publishing setup with GitHub Actions + +## 1.1.1 + +- Add automated publishing support to pub.dev + +## 1.1.0 + +- Add showNowPlaying, it can be called multiple times safely ([#33](https://github.com/oguzhnatly/flutter_carplay/issues/33)) (ty @vanlooverenkoen, @EArminjon) +- Add support for HTTP(s) images (ty @vanlooverenkoen) +- Add support to launch CarPlay without manually launch the iOS app ([#25](https://github.com/oguzhnatly/flutter_carplay/pull/25)) (ty @vanlooverenkoen) +- Update the iOS integration and its doc to fix various issues ([#17](https://github.com/oguzhnatly/flutter_carplay/issues/17), [#35](https://github.com/oguzhnatly/flutter_carplay/issues/35), [#38](https://github.com/oguzhnatly/flutter_carplay/issues/38), [#61](https://github.com/oguzhnatly/flutter_carplay/issues/61), [#67](https://github.com/oguzhnatly/flutter_carplay/issues/67)) (ty @EArminjon, @snipd-mikel) + +## 1.0.3 + +- Build fix for the issue [#7](https://github.com/oguzhnatly/flutter_carplay/issues/7) + +## 1.0.2+1 + +- Point Of Interest and Information Template added. Previews added to README.md. + +## 1.0.2 + +- Point Of Interest and Information Template added. + +## 1.0.1 + +- CarPlay List Template issue #4 fixed. + +## 1.0.0+1 + +- Initial release of Flutter Apple CarPlay Package. Previews added to README.md. + +## 1.0.0 + +- Initial release of Flutter Apple CarPlay Package. diff --git a/third_party/flutter_carplay/LICENSE b/third_party/flutter_carplay/LICENSE new file mode 100644 index 0000000..1ae9910 --- /dev/null +++ b/third_party/flutter_carplay/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Oğuzhan Atalay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/third_party/flutter_carplay/README.md b/third_party/flutter_carplay/README.md new file mode 100644 index 0000000..87bd913 --- /dev/null +++ b/third_party/flutter_carplay/README.md @@ -0,0 +1,1438 @@ +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/banner.png) + +# CarPlay and Android Auto with Flutter 🚗 + +[![License: MIT](https://img.shields.io/badge/License-MIT-orange.svg)](https://opensource.org/licenses/MIT) +![Pub Version (including pre-releases)](https://img.shields.io/pub/v/flutter_carplay?include_prereleases) +![Dart Pub Likes](https://badgen.net/pub/likes/flutter_carplay) +![Dart Pub Multi-Platform](https://badgen.net/pub/flutter-platform/flutter_carplay) +![DartPub Dart SDK](https://badgen.net/pub/sdk-version/flutter_carplay) + +Flutter Apps now on Apple CarPlay and Android Auto ! `flutter_carplay` aims to make it safe to use apps made with Flutter in the car by integrating with CarPlay and Android Auto. The package takes the things you want to do while driving and puts them on the car's built-in display. + +**✨ New in v1.5.0**: Android Auto alert, grid, and tab bar templates, modal alert APIs, and richer Android Auto list loading states. + +**✨ New in v1.1.0**: CarPlay apps can now launch automatically without requiring the Flutter app to be opened first, supporting true background launch capabilities. + +> Apple announced some great features in iOS 14, one of which is users download CarPlay apps from the App Store and use them on iPhone like any other app. When an iPhone with a CarPlay app is connected to a CarPlay vehicle, the app icon appears on the CarPlay home screen. CarPlay apps are not separate apps—you add CarPlay support to an existing app. +> +> Your app uses the CarPlay framework to present UI elements to the user. iOS manages the display of UI elements and handles the interface with the car. Your app does not need to manage the layout of UI elements for different screen resolutions, or support different input hardware such as touchscreens, knobs, or touch pads. + +It supports **only iOS 14.0+**. For general design guidance, see [Human Interface Guidelines for CarPlay Apps](https://developer.apple.com/design/human-interface-guidelines/carplay/overview/introduction/). + +## 📚 Documentation + +For detailed guides and examples, check out the **[Wiki](https://github.com/oguzhnatly/flutter_carplay/wiki)**: + +- [Getting Started](https://github.com/oguzhnatly/flutter_carplay/wiki/Getting-Started) — Installation and basic setup +- [iOS Setup](https://github.com/oguzhnatly/flutter_carplay/wiki/iOS-Setup) — CarPlay entitlements and configuration +- [Android Auto Setup](https://github.com/oguzhnatly/flutter_carplay/wiki/Android-Auto-Setup) — Android Auto configuration +- [Templates](https://github.com/oguzhnatly/flutter_carplay/wiki/Templates) — All templates with code examples +- [Troubleshooting](https://github.com/oguzhnatly/flutter_carplay/wiki/Troubleshooting) — Common issues and solutions +- [FAQ](https://github.com/oguzhnatly/flutter_carplay/wiki/FAQ) — Frequently asked questions + +# Summary + +- [Overview](#overview) +- [Templates](#templates) +- [Supports](#supports) +- [What's New in latest versions](#whats-new-in-latest-versions) +- [Road Map](#road-map) +- [Contributing](#contributing) +- [Requesting the CarPlay Entitlements](#requesting-the-carplay-entitlements) +- [Disclaimer Before The Installation](#disclaimer-before-the-installation) +- [Get Started](#get-started) +- [Android Auto vs Android Automotive OS](#android-auto-vs-android-automotive-os) +- [Solve problems configuring your project](#solve-problems-configuring-your-project) +- [Usage & Features](#usage--features) +- [Templates](#templates-1) +- [LICENSE](#license) + +# Overview + +![Flutter CarPlay Introduction](https://user-images.githubusercontent.com/54781138/131184549-3cb62678-ad3f-4d67-85fb-1410bd05eaff.gif) + +Before you begin CarPlay integration, you must carefully read this section. + +[_The official App Programming Guidelines from Apple_](https://developer.apple.com/carplay/documentation/CarPlay-App-Programming-Guide.pdf) is the most valuable resource for understanding the needs, limits, and capabilities of CarPlay Apps. This documentation is a 49-page which clearly spells out the some actions required, and you are strongly advised to read it. If you are interested in a CarPlay System, [learn more about the MFi Program](https://mfi.apple.com/). + +# Templates + +## Car Play Templates + +CarPlay apps are built from a fixed set of user interface templates that iOS renders on the CarPlay screen. Each CarPlay app category can only use a restricted number of templates. Your app entitlement determines your access to templates. + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/templates.png) + +## Android Auto Templates + +Android Auto apps built with the Android for Cars App Library are constructed using a fixed set of vehicle-optimized templates that the host renders on the car screen. Each Android Auto app category (e.g., Navigation, Point-of-Interest, IoT, etc.) can only use a restricted number of templates, and access to the Android for Cars App Library and its templates is generally restricted to supported app categories. + +https://developer.android.com/design/ui/cars/guides/templates/overview + +# Supports + +## Car Play Support + +`flutter_carplay` currently supports: + +- [x] Action Sheet Template +- [x] Alert Template +- [x] Grid Template +- [x] List Template +- [x] Tab Bar Template +- [x] Information Template (contribution from [OSch11](https://github.com/OSch11/flutter_carplay)) +- [x] Point of Interest Template (contribution from [OSch11](https://github.com/OSch11/flutter_carplay)) +- [x] Search Template +- [x] Now Playing Template (v1.1.0) + +By evaluating this information, you can request for the relevant entitlement from Apple. + +## Android Auto Support + +- [x] List Template (limited support) +- [x] Grid Template +- [x] Tab Bar Template (requires Car App API level 6+) +- [x] Alert Template +- [x] Message Template +- [x] Long Message Template +- [x] Pane Template +- [x] Now Playing Template (Automatically handled by Android Auto system) + +# What's New in latest versions + +## v1.5.0 + +- **🚘 More Android Auto Templates**: Added alert, grid, and tab bar templates, including tab selection handling +- **⚠️ Android Auto Alerts**: Added modal alert presentation and dismissal APIs for Android Auto flows +- **🧾 Better Android Auto Lists**: Added loading messages and empty view title support for list templates + +## v1.4.0 + +- **🔎 CarPlay Search Template**: Added `CPSearchTemplate` with search text, result selection, and search button callbacks +- **🤖 More Android Auto Templates**: Added `AAMessageTemplate`, `AALongMessageTemplate`, and `AAPaneTemplate`, including update APIs +- **🧾 Better Android Auto Lists**: Added stable IDs, section selection, toggles, browsable rows, and trailing images +- **🖼️ Flutter Asset SVG Support**: Flutter asset SVGs are rasterized before reaching native CarPlay and Android Auto image fields +- **📚 Android Auto Docs**: Clarified that Android Auto template rendering is different from Android Automotive OS apps + +## v1.3.0 + +- **🖼️ CPListImageRowItem**: Added support for image row list items, including element based layouts on newer iOS versions +- **🔄 Information Template Updates**: Added update methods for information items and actions without rebuilding the whole template, thanks to [@sINFdorako](https://github.com/sINFdorako) +- **🧩 Better Tab Bar Configuration**: `tabTitle`, `systemIcon`, and `showsTabBadge` are now exposed consistently on templates +- **🛠️ API Polish**: Added custom ids across models, convenient update helpers, and improved image loading reliability + +## v1.2.0 + +- **🤖 Android Auto Support**: Initial support for Android Auto with limited + features (Thanks to [@EArminjon](https://github.com/EArminjon)) + +## v1.1.0 + +- **🚀 Background Launch Support**: CarPlay apps can now start automatically without requiring the Flutter app to be opened first (Thanks to [@vanlooverenkoen](https://github.com/vanlooverenkoen) and [@EArminjon](https://github.com/EArminjon)) +- **🎵 Now Playing Template**: Navigate to the shared instance of the Now Playing Template with `FlutterCarplay.showSharedNowPlaying()` +- **🌐 Flexible Image Sources**: Load images from assets, local files (`file://`), or URLs (`https://`) (Thanks to [@vanlooverenkoen](https://github.com/vanlooverenkoen)) +- **🔧 Improved Completion Handlers**: Better reliability for list item interactions and template transitions +- **📱 Flutter 3.32.x Compatibility**: Updated for the latest Flutter versions + +Special thanks to [@EArminjon](https://github.com/EArminjon), [@vanlooverenkoen](https://github.com/vanlooverenkoen), [@snipd-mikel](https://github.com/snipd-mikel), [@APIUM](https://github.com/APIUM), and all contributors who made this release possible! + +# Road Map + +Other templates will be supported in the future releases by `flutter_carplay`. + +## Car Play Road Map + +- [ ] Map Template +- [x] Search Template +- [ ] Voice Control & "Hey Siri" for hands-free voice activation +- [ ] Contact Template + +## Android Auto Road Map +- [ ] Action Sheet Template +- [x] Information Template via Pane Template +- [ ] Point of Interest Template +- [ ] Map Template +- [ ] Search Template +- [ ] Voice Control & "Hey Google" for hands-free voice activation +- [ ] Contact Template + +# Contributing + +- Pull Requests are always welcome. +- Pull Request Reviews are even more welcome! I need help in testing. +- If you are interested in contributing more actively, please contact me at info@oguzhanatalay.com Thanks! +- If you want to help in coding, join [Discord Server](https://discord.gg/Xz6WVezFfh), so we can chat over there. + +# Requesting the CarPlay Entitlements + +> All CarPlay apps require a CarPlay app entitlement. + +If you want to build, run and publish your app on Apple with CarPlay compatibility or test or share the app with others through the TestfFlight or AdHoc, you must first request Apple to approve your Developer account for CarPlay access. The process can take from a few days to weeks or even months. It depends on the type of Entitlement you are requesting. + +To request a CarPlay app entitlement from Apple, go to https://developer.apple.com/contact/carplay and provide information about your app, including the CarPlay App Category. You must also agree to the CarPlay Entitlement Addendum. + +With this project, you can start developing and testing through Apple's CarPlay Simulator without waiting for CarPlay Entitlements. Apple will review your request. If your app meets the criteria for a CarPlay app, Apple will assign a CarPlay app entitlement to your Apple Developer Account and will notify you. + +Whether you are running the app through a simulator or developing it for distribution, you must ensure that the relevant entitlement key is added to the `Entitlements.plist` file. You must create an Entitlements.plist file if you do not already have one. + +## After you receive the CarPlay Entitlement + +After you receive the entitlement, you need to configure your Xcode project to use it, which involves several steps. You create and import a provisioning profile, and add an `Entitlements.plist` file. Your project’s code signing settings also require minor changes. + +For more detailed instructions about how to create and import the CarPlay Provisioning Profile and add an Entitlements File to Xcode Project, go to [Configure your CarPlay-enabled app with the entitlements it requires.](https://developer.apple.com/documentation/carplay/requesting_the_carplay_entitlements) + +Choose the CarPlay entitlement that matches the app category Apple approved for your project. The example app keeps `com.apple.developer.carplay-parking` so the Point of Interest demo remains available. If your app uses a different CarPlay category, replace it with the matching entitlement, for example `com.apple.developer.carplay-maps` for maps and navigation, `com.apple.developer.carplay-quick-ordering` for quick ordering, `com.apple.developer.carplay-charging` for EV charging, `com.apple.developer.carplay-fueling` for fuel stations, `com.apple.developer.carplay-driving-task` for driving tasks, `com.apple.developer.carplay-communication` for calling or messaging, or `com.apple.developer.carplay-audio` for audio playback. + +# Disclaimer Before The Installation + +You are about to make some minor changes to your Xcode project after installing this package. This is due to the fact that It requires bitcode compilation which is missing in Flutter. You will procedure that will relocate (we won't remove or edit) some Flutter and its package engines. If you're planning to add this package to a critical project for you, you should proceed cautiously. + +**Please check [THE EXAMPLE PROJECT](https://github.com/oguzhnatly/flutter_carplay/tree/master/example) before you begin to the installation.** + +THE INSTALLATION STEPS MAY BE DIFFICULT OR MAY NOT WORK PROPERLY WITH A FEW PACKAGES IN YOUR CURRENT PROJECT THAT COMMUNICATE WITH THE FLUTTER ENGINE. IF YOU ARE NOT COMPLETELY SURE WHAT YOU ARE DOING, PLEASE CREATE AN ISSUE, SO THAT I CAN HELP YOU TO SOLVE YOUR PROBLEM OR EXPLAIN WHAT YOU NEED TO. + +WHILE THE INSTALLATION PROGRESS, IF YOU TRY TO CHANGE ANYTHING (E.G. ANYTHING WORKS WITH FLUTTER ENGINE, ANYTHING IN GENERATED PLUGIN REGISTRANT SPECIFICALLY ITS LOCATION, ANY FILE NAME, ANY CLASS NAME, OR ANY OTHER FUNCTION THAT WORKS ON APPDELEGATE CLASS, TEMPLATE OR WINDOW APPLICATION DELEGATE SCENE NAMES USED IN INFO.PLIST, INCLUDED STORYBOARD NAMES, BUT NOT LIMITED TO THESE), YOU ARE MOST LIKELY TO ENCOUNTER IRREVERSIBLE ERRORS AND IT MAY DAMAGE TO YOUR PROJECT. I STRONGLY RECOMMEND THAT YOU SHOULD COPY YOUR EXISTING PROJECT BEFORE THE INSTALLATION. + +# Get Started + +## Car Play Get Started + +### Requirement Actions after Installation of the Package + +1. The iOS platform version must be set to 14.0. To make it global, navigate to `ios/Podfile` and copy the first two lines: + +```diff +# Uncomment this line to define a global platform for your project ++ platform :ios, '14.0' +- # platform :ios, '9.0' +``` + +After changing the platform version, execute the following command in your terminal to update your pod files: + +```shell +// For Apple Silicon M1 chips: +$ cd ios && arch -x86_64 pod install --repo-update + +// For Intel chips: +$ cd ios && pod install --repo-update +``` + +2. Open `ios/Runner.xcworkspace` in Xcode. In your project navigator, open `AppDelegate.swift`. + + ![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/step2.png) + + Delete the specified codes below from the application function in `AppDelegate.swift`, and change it with the code below: + +```diff +import UIKit +import Flutter + +let flutterEngine = FlutterEngine(name: "SharedEngine", project: nil, allowHeadlessExecution: true) + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { ++ flutterEngine.run() ++ GeneratedPluginRegistrant.register(with: flutterEngine) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} +``` + +3. Create a swift file named `SceneDelegate.swift` in the Runner folder (not in the xcode main project file) and add the code below: + + ```swift + @available(iOS 13.0, *) + class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + + window = UIWindow(windowScene: windowScene) + + let controller = FlutterViewController.init(engine: flutterEngine, nibName: nil, bundle: nil) + controller.loadDefaultSplashScreenView() + window?.rootViewController = controller + window?.makeKeyAndVisible() + } + } + ``` + + ![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/step3.png) + +4. One more step, open the `Info.plist` file whether in your favorite code editor or in the Xcode. I'm going to share the base code, so if you open in the Xcode, you can fill with the raw keys with the values. + + ```xml + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + CPTemplateApplicationSceneSessionRoleApplication + + + UISceneConfigurationName + CarPlay Configuration + UISceneDelegateClassName + flutter_carplay.FlutterCarPlaySceneDelegate + + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + ``` + +### That's it, you're ready to build your first CarPlay app! 🚀 😎 + +## Android Auto Get Started + +### Requirement Actions after Installation of the Package + +1. The Android platform version must be set to 21. Update your `android/app/build.gradle.kts` as below: + +```diff +# Update to use at minimum api 21 ++ minSdk = 21 +- minSdk = 19 +``` + +2. To setup Android Auto, you need to add a metadata tag in your `AndroidManifest.xml` file. Open `android/app/src/main/AndroidManifest.xml` and add the following : + + +Inside the `` tag: + +```xml + + + + + + +``` + +Inside the `` tag: + +```xml + + + + + + + + + + + + + +``` + +3. Create a new directory named `xml` inside `android/app/src/main/res/` if it doesn't already exist. Then, create a new XML file named `automotive_app_desc.xml` in the `res/xml/` directory and add the following content: + +```xml + + + + + + + +``` + +For others use, please check official [Android Auto documentation](https://developer.android.com/training/cars/apps/auto). + +4. In your `MainActivity.kt` file, make the necessary to resuse and cache the engine as follow : + +On Android Auto Service, use the same engine as the app if the app is already running, otherwise create a new one and cache using the id `FAAConstants.flutterEngineId`. +To avoid creating multiple engines, you need to override the `provideFlutterEngine` and `configureFlutterEngine` methods as below : + +```kotlin +package com.example.flutter_carplay_example + +import android.content.Context +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.FlutterEngineCache +import io.flutter.embedding.engine.dart.DartExecutor +import com.oguzhnatly.flutter_android_auto.FAAConstants + +class MainActivity : FlutterActivity() { + override fun provideFlutterEngine(context: Context): FlutterEngine? { + // Use engine from cache if it has been started by Android Auto. + return FlutterEngineCache.getInstance().get(FAAConstants.flutterEngineId); + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + // Cache the engine to make it usable by Android Auto. + FlutterEngineCache.getInstance().put(FAAConstants.flutterEngineId, flutterEngine) + super.configureFlutterEngine(flutterEngine) + } +} +``` + +## Android Auto vs Android Automotive OS + +Android Auto and Android Automotive OS (AAOS) are different targets: + +- **Android Auto** is a projected experience. The app runs on a phone, and the vehicle display is rendered by an Android Auto host using templates from the Android for Cars App Library. This is what `flutter_carplay` supports on Android. +- **Android Automotive OS** is Android running directly in the vehicle. A Flutter app can be installed and launched on AAOS like any other Android app, but that opens the app's normal Android activity and shows the regular Flutter UI. + +`flutter_carplay` does not convert a Flutter app into a native AAOS app and does not render Android Auto templates when the app is opened normally on AAOS. The templates are rendered only by a compatible Android Auto host. + +In practice: + +- Use this package for Android Auto template apps. +- Do not expect additional behavior for a normal Flutter app installed on AAOS. +- If you are building a full native AAOS app, build and test the Flutter Android app UI directly for the vehicle environment instead of relying on Android Auto templates. + +For Android Auto testing from a phone emulator or device, use the Android Auto Desktop Head Unit instructions in the official [Android Auto testing documentation](https://developer.android.com/training/cars/testing). + +## Solve problems configuring your project + +Take a look at [this detailed issue reply](https://github.com/oguzhnatly/flutter_carplay/issues/3#issuecomment-926146126) if you got any error. + +## Usage & Features + +To see a complete example for both CarPlay and Android Auto, check the example project. + +[**See Full Example**](https://github.com/oguzhnatly/flutter_carplay/blob/master/example/lib/main.dart) + +### Basic Usage for Android Auto + +Import all the classes you need from a single file: + +```dart +import 'package:flutter_carplay/flutter_carplay.dart'; +``` + +Initialize the Android Auto controller and set a root template: + +```dart +final FlutterAndroidAuto _androidAuto = FlutterAndroidAuto(); + +await FlutterAndroidAuto.setRootTemplate( + template: AATabBarTemplate( + tabs: [ + AAListTemplate( + title: 'Home', + tabTitle: 'Home', + systemIcon: 'house.fill', + sections: [ + AAListSection( + items: [ + AAListItem( + title: 'Item 1', + subtitle: 'Detail Text', + onPress: (complete, self) { + complete(); + }, + ), + ], + ), + ], + ), + ], + ), +); +``` + +> It is recommended to set the root template in the first `initState` of your app, after Android Auto is connected. + +### Listen Connection Changes for Android Auto + +```dart +_androidAuto.addListenerOnConnectionChange(onAndroidAutoConnectionChange); + +void onAndroidAutoConnectionChange(ConnectionStatusTypes status) { + // ConnectionStatusTypes.connected + // ConnectionStatusTypes.disconnected + // ConnectionStatusTypes.unknown +} + +_androidAuto.removeListenerOnConnectionChange(); +``` + +### Android Auto API Methods + +#### **FlutterAndroidAuto.setRootTemplate** + +Sets the root template of the navigation hierarchy. If one already exists, it replaces it entirely. + +The template must be one of: **AATabBarTemplate**, **AAGridTemplate**, **AAListTemplate**, **AAPaneTemplate**, **AAMessageTemplate**, or **AALongMessageTemplate**. + +```dart +await FlutterAndroidAuto.setRootTemplate( + template: /* Android Auto template */, +); +``` + +#### **FlutterAndroidAuto.push** + +Adds a template to the navigation hierarchy and displays it. + +The template must be one of: **AAGridTemplate**, **AAListTemplate**, **AAPaneTemplate**, **AAMessageTemplate**, or **AALongMessageTemplate**. + +```dart +await FlutterAndroidAuto.push( + template: /* Android Auto template */, +); +``` + +#### **FlutterAndroidAuto.showAlert** + +Presents an `AAAlertTemplate` as a full-screen modal. Android Auto does not support true overlay modals, so the alert is pushed onto the navigation stack as a `MessageTemplate`. + +```dart +await FlutterAndroidAuto.showAlert(template: alertTemplate); +``` + +#### **FlutterAndroidAuto.popModal** + +Dismisses the currently presented `AAAlertTemplate`. + +```dart +await FlutterAndroidAuto.popModal(); +``` + +#### **FlutterAndroidAuto.updateTabBarTemplates** + +Updates the tabs of the currently displayed `AATabBarTemplate` without rebuilding the root from Dart. + +```dart +await FlutterAndroidAuto.updateTabBarTemplates(template: updatedTabBarTemplate); +``` + +#### **FlutterAndroidAuto.updatePaneTemplate** + +Updates an existing `AAPaneTemplate` and invalidates its Android Auto screen. + +```dart +await FlutterAndroidAuto.updatePaneTemplate(template: paneTemplate); +``` + +### Android Auto Message Template + +Use `AAMessageTemplate` for simple empty states, errors, or informational screens. + +```dart +final template = AAMessageTemplate( + title: 'No saved places', + message: 'Save places on your phone to access them here.', +); + +await FlutterAndroidAuto.setRootTemplate(template: template); + +await template.update( + title: 'Saved places synced', + message: 'Your saved places are now available in Android Auto.', +); +``` + +Use `AALongMessageTemplate` for longer informational text that needs more room +than a simple message template. + +```dart +final template = AALongMessageTemplate( + title: 'Safety information', + message: 'Keep your attention on the road. This longer Android Auto message ' + 'template is intended for content that needs more space.', +); + +await FlutterAndroidAuto.push(template: template); +``` + +### Android Auto Pane Template + +Use `AAPaneTemplate` for compact informational screens on Android Auto. It maps to Android's native `PaneTemplate` and is the closest Android equivalent for CarPlay-style information screens. + +```dart +await FlutterAndroidAuto.push( + template: AAPaneTemplate( + title: 'Vehicle Info', + items: [ + AAPaneItem(title: 'Battery', detail: '82%'), + AAPaneItem( + title: 'Navigation', + detail: 'Route ready', + imageUrl: 'images/svg_navigation.svg', + imageTint: const AutoImageTint.platform(), + ), + ], + actions: [ + AAPaneAction( + title: 'Refresh', + isPrimary: true, + onPress: () { + // Refresh content. + }, + ), + ], + ), +); +``` + +Pane rows are informational on Android and cannot be tapped. Use pane actions for user interaction. + +### Flutter Asset SVG Images + +Flutter asset SVGs can be used in image fields such as `CPListItem.image`, `CPGridButton.image`, `CPPointOfInterest.image`, `CPListImageRowItem` image collections, `AAListItem.imageUrl`, and `AAPaneTemplate` image fields. The package rasterizes local `.svg` assets to PNG bytes before sending them to the native CarPlay or Android Auto layer. Remote SVG URLs and `file://` SVGs are not rasterized. + +### Basic Usage for Car Play + +- Import the all classes that you need from just one file: + +```dart +import 'package:flutter_carplay/flutter_carplay.dart'; +``` + +- Initialize the CarPlay Controllers, set a root template for the CarPlay view hierarchy and ensure to well update the root template : + +```dart +final FlutterCarplay _flutterCarplay = FlutterCarplay(); + +await FlutterCarplay.setRootTemplate( + rootTemplate: CPTabBarTemplate( + templates: [ + CPListTemplate( + sections: [ + CPListSection( + items: [ + CPListItem( + text: "Item 1", + detailText: "Detail Text", + accessoryImage: 'images/logo_flutter_1080px_clr.png', + onPress: (complete, self) { + self.setDetailText("You can change the detail text.. 🚀"); + self.setAccessoryImage('images/logo_flutter_1080px_clr.png'); + Future.delayed(const Duration(seconds: 1), () { + self.setDetailText("Customizable Detail Text"); + complete(); + }); + }, + ), + ], + header: "First Section", + ), + ], + title: "Home", + showsTabBadge: false, + systemIcon: "house.fill", + ), + ], + ), + animated: true, +); +_flutterCarplay.forceUpdateRootTemplate(); +``` + +### CarPlay Search Template + +![Flutter CarPlay Search Template](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/search_template.png) + +Use `CPSearchTemplate` when your CarPlay app needs a native search screen. Your app's CarPlay entitlement controls which templates are available for its approved category. The example app keeps the parking entitlement for Point of Interest support; switch the entitlement in your app target if Apple approved a different category. Return result rows from `onUpdatedSearchText`, handle row selection in `onSelectedResult`, and call the provided completion callback after your app finishes handling the selected result. + +```dart +await FlutterCarplay.push( + template: CPSearchTemplate( + onUpdatedSearchText: (searchText, update) { + update([ + CPListItem( + text: 'Result for $searchText', + detailText: 'Tap to select', + ), + ]); + }, + onSelectedResult: (selectedItem, complete) { + complete(); + }, + ), +); +``` + +> You can set a root template without initializing the CarPlay Controllers, but some callback functions may not work or most likely you will get an error. + +> It's recommended that you should set the root template in the first initState of your app. + +### Basic Usage for Android Auto + +Android Auto row/list affordances follow the AndroidX Car App API names. Selectable lists render radio buttons on every row, `isBrowsable` renders the system navigation affordance, and `toggle` renders a switch in the row. + +```dart +final FlutterAndroidAuto flutterAndroidAuto = FlutterAndroidAuto(); + +await FlutterAndroidAuto.setRootTemplate( + template: AAListTemplate( + title: 'Home', + sections: [ + AAListSection( + selectedIndex: 0, + onSelected: (selectedIndex, selectedItem) { + print('Selected $selectedIndex: ${selectedItem.title}'); + }, + items: [ + AAListItem(title: 'Radio option 1'), + AAListItem(title: 'Radio option 2'), + ], + ), + AAListSection( + title: 'Rows', + items: [ + AAListItem( + title: 'Open details', + isBrowsable: true, + onPress: (complete, item) { + complete(); + }, + ), + AAListItem( + title: 'Toggle item', + toggle: AAToggle( + isChecked: true, + onCheckedChange: (checked, item) { + print('${item.title}: $checked'); + }, + ), + ), + ], + ), + ], + ), +); +flutterAndroidAuto.forceUpdateRootTemplate(); +``` + +### Listen Connection Changes + +You can detect connection changes, such as when CarPlay is connected to iPhone, is in the background, or is completely disconnected. + +```dart +/// Add the listener +_flutterCarplay.addListenerOnConnectionChange(onCarplayConnectionChange); + +void onCarplayConnectionChange(ConnectionStatusTypes status) { + // Do things when carplay connection status is: + // - ConnectionStatusTypes.connected + // - ConnectionStatusTypes.background + // - ConnectionStatusTypes.disconnected + // - ConnectionStatusTypes.unknown +} + +/// Remove the listener +_flutterCarplay.removeListenerOnConnectionChange(); +``` + +### CarPlay API Methods + +#### **CarPlay.setRootTemplate** + +Sets the root template of the navigation hierarchy. If a navigation +hierarchy already exists, CarPlay replaces the entire hierarchy. + +- rootTemplate is a template to use as the root of a new navigation hierarchy. If one exists, + it will replace the current rootTemplate. **Must be one of the type:** + **CPTabBarTemplate**, **CPGridTemplate**, **CPListTemplate**, **CPInformationTemplate**, **CPPointOfInterestTemplate**, or **CPSearchTemplate**. If not, it will throw a **TypeError**. +- If animated is true, CarPlay animates the presentation of the template, but will be ignored + this flag when there isn’t an existing navigation hierarchy to replace. + +> CarPlay cannot have more than 5 templates on one screen. + +```dart +FlutterCarplay.setRootTemplate( + rootTemplate: /* CPTabBarTemplate, CPGridTemplate, CPListTemplate, CPInformationTemplate, CPPointOfInterestTemplate, or CPSearchTemplate */, + animated: true, +); +// You need to call _flutterCarplay.forceUpdateRootTemplate(); after setting the root template +``` + +#### **CarPlay.push** + +Adds a template to the navigation hierarchy and displays it. + +- template is to add to the navigation hierarchy. **Must be one of the type:** **CPGridTemplate**, **CPListTemplate**, **CPInformationTemplate**, **CPPointOfInterestTemplate**, or **CPSearchTemplate**. If not, it will throw a **TypeError**. +- If animated is true, CarPlay animates the transition between templates. + +> There is a limit to the number of templates that you can push onto the screen. All apps are limited to pushing up to 5 templates in depth, including the root template. + +```dart +FlutterCarplay.push( + template: /* CPGridTemplate, CPListTemplate, CPInformationTemplate, CPPointOfInterestTemplate, or CPSearchTemplate */, + animated: true, +); +``` + +#### **CarPlay.pop** + +Removes the top-most template from the navigation hierarchy. + +- If animated is true, CarPlay animates the transition between templates. +- count represents how many times this function will occur. + +```dart +FlutterCarplay.pop(); +// OR +FlutterCarplay.pop(animated: true, count: 1); +``` + +#### **CarPlay.popToRoot** + +Removes all of the templates from the navigation hierarchy except the root template. + +- If animated is true, CarPlay animates the presentation of the template. + +```dart +FlutterCarplay.popToRoot(animated: true); +``` + +#### **CarPlay.popModal** + +Removes a modal template. Since **CPAlertTemplate** and **CPActionSheetTemplate** are both modals, they can be removed. + +- If animated is true, CarPlay animates the transition between templates. + +```dart +FlutterCarplay.popModal(animated: true); +``` + +#### **CarPlay.showSharedNowPlaying** + +Navigate to the shared instance of the Now Playing Template. This allows users to control media playback directly from CarPlay. + +- If animated is true, CarPlay animates the transition to the Now Playing template. + +```dart +FlutterCarplay.showSharedNowPlaying(animated: true); +``` + +#### **CarPlay.connectionStatus** + +Getter for current CarPlay connection status. It will return one of **ConnectionStatusTypes** as String. + +```dart +FlutterCarplay.connectionStatus +``` + +## Templates + +CarPlay supports general purpose templates such as alerts, lists, and tab bars. They are used to display contents on the CarPlay screen from the app. [The Developer Guide](https://developer.apple.com/carplay/documentation/CarPlay-App-Programming-Guide.pdf) contains more information on the templates that Apple supports. + +> If you attempt to use a template not supported by your entitlement, an exception will occur at runtime. + +### Tab Bar Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/tabbar_template.png) + +The tab bar is a multi-purpose container for other templates, with each template occupying one tab in the tab bar. + +```dart +final CPTabBarTemplate tabBarTemplate = CPTabBarTemplate( + templates: [ + CPListTemplate( + sections: [ + CPListSection( + items: [ + CPListItem( + text: "Item 1", + detailText: "Detail Text", + onPress: (complete, self) { + // Returns the self class so that the item + // can be updated within self while loading + self.setDetailText("You can change the detail text.. 🚀"); + // complete function stops the loading + complete(); + }, + // Supports three image formats (v1.1.0+): + // - Asset: 'images/logo_flutter_1080px_clr.png' + // - File: 'file:///path/to/local/image.png' + // - URL: 'https://example.com/image.png' + image: 'images/logo_flutter_1080px_clr.png', + ), + CPListItem( + text: "Item 2", + detailText: "Start progress bar", + isPlaying: false, + playbackProgress: 0, + // asset name defined in pubspec.yaml + image: 'images/logo_flutter_1080px_clr.png', + onPress: (complete, self) { + complete(); + }, + ), + ], + header: "First Section", + ), + ], + title: "Home", + showsTabBadge: false, + systemIcon: "house.fill", + ), + CPListTemplate( + sections: [], + title: "Settings", + // If there is no section in the list template, + // empty view title and subtitle variants will be shown + emptyViewTitleVariants: ["Settings"], + emptyViewSubtitleVariants: [ + "No settings have been added here yet. You can start adding right away" + ], + showsTabBadge: false, + systemIcon: "gear", + ), + ], +); + +FlutterCarplay.setRootTemplate(rootTemplate: tabBarTemplate, animated: true); +``` + +### Grid Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/grid_template.png) + +Grid Template is a specific style of menu that presents up to 8 items represented by an image and a title. Use the grid template to let people select from a fixed list of categories. + +```dart +final CPGridTemplate gridTemplate = CPGridTemplate( + title: "Grid Template", + buttons: [ + for (var i = 1; i < 9; i++) + CPGridButton( + titleVariants: ["Item $i"], + image: 'images/logo_flutter_1080px_clr.png', + onPress: () { + print("Grid Button $i pressed"); + }, + ), + ], +); + +FlutterCarplay.push(template: gridTemplate, animated: true); +// OR +FlutterCarplay.setRootTemplate(rootTemplate: gridTemplate, animated: true); +// You need to call _flutterCarplay.forceUpdateRootTemplate(); after setting the root template +``` + +### Alert Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/alert_template.png) + +Alerts provide important information about your app's status. An alert consists of a title and one or more buttons, depending on the type. + +> If underlying conditions permit, alerts can be dismissed programatically. + +```dart +final CPAlertTemplate alertTemplate = CPAlertTemplate( + titleVariants: ["Alert Title"], + actions: [ + CPAlertAction( + title: "Okay", + style: CPAlertActionStyles.normal, + onPress: () { + print("Okay pressed"); + FlutterCarplay.popModal(animated: true); + }, + ), + CPAlertAction( + title: "Cancel", + style: CPAlertActionStyles.cancel, + onPress: () { + print("Cancel pressed"); + FlutterCarplay.popModal(animated: true); + }, + ), + CPAlertAction( + title: "Remove", + style: CPAlertActionStyles.destructive, + onPress: () { + print("Remove pressed"); + FlutterCarplay.popModal(animated: true); + }, + ), + ], +), + +FlutterCarplay.showAlert(template: alertTemplate, animated: true); +``` + +### Action Sheet Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/actionsheet_template.png) + +Action Sheet Template is a type of alert that appears when control or action is taken and gives a collection of options based on the current context. + +> Use action sheets to let people initiate tasks, or to request confirmation before performing a potentially destructive operation. + +```dart +final CPActionSheetTemplate actionSheetTemplate = CPActionSheetTemplate( + title: "Action Sheet Template", + message: "This is an example message.", + actions: [ + CPAlertAction( + title: "Cancel", + style: CPAlertActionStyles.cancel, + onPress: () { + print("Cancel pressed in action sheet"); + FlutterCarplay.popModal(animated: true); + }, + ), + CPAlertAction( + title: "Dismiss", + style: CPAlertActionStyles.destructive, + onPress: () { + print("Dismiss pressed in action sheet"); + FlutterCarplay.popModal(animated: true); + }, + ), + CPAlertAction( + title: "Ok", + style: CPAlertActionStyles.normal, + onPress: () { + print("Ok pressed in action sheet"); + FlutterCarplay.popModal(animated: true); + }, + ), + ], +); + +FlutterCarplay.showActionSheet(template: actionSheetTemplate, animated: true); +``` + +### List Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/list_template.png) + +A list presents data as a scrolling, single-column table of rows that can be divided into sections. Lists are ideal for text-based content, and can be used as a means of navigation for hierarchical information. Each item in a list can include attributes such as an icon, title, subtitle, disclosure indicator, progress indicator, playback status, or read status. + +> Some cars dynamically limit lists to a maximum of 12 items. You always need to be prepared to handle the case where only 12 items can be shown. Items beyond the maximum will not be shown. + +```dart +final CPListTemplate listTemplate = CPListTemplate( + sections: [ + CPListSection( + items: [ + CPListItem( + text: "Item 1", + detailText: "Detail Text", + onPress: (complete, self) { + // Returns the self class so that the item + // can be updated within self while loading + self.setDetailText("You can change the detail text.. 🚀"); + // complete function stops the loading + complete(); + }, + image: 'images/logo_flutter_1080px_clr.png', + accessoryImage: 'images/logo_flutter_1080px_clr.png', + ), + CPListItem( + text: "Item 2", + detailText: "Start progress bar", + isPlaying: false, + playbackProgress: 0, + // asset name defined in pubspec.yaml + image: 'images/logo_flutter_1080px_clr.png', + onPress: (complete, self) { + complete(); + }, + ), + ], + header: "First Section", + ), + ], + title: "Home", + showsTabBadge: false, + systemIcon: "house.fill", + // If there is no section in the list template, + // empty view title and subtitle variants will be shown + emptyViewTitleVariants: ["Home"], + emptyViewSubtitleVariants: [ + "Nothing has added here yet. You can start adding right away" + ], +); + +FlutterCarplay.push(template: listTemplate, animated: true); +// OR +FlutterCarplay.setRootTemplate(rootTemplate: listTemplate, animated: true); +// You need to call _flutterCarplay.forceUpdateRootTemplate(); after setting the root template +``` + +### Information Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/information_template.png) + +An Information Template shows a list of items, and actions (max. three)) as array of text buttons. + +> The list is limited to 10 items. Items beyond the maximum will not be shown. Up to three actions are supported. + +```dart +final CPInformationTemplate informationTemplate = CPInformationTemplate( + title: "Title", + layout: CPInformationTemplateLayout.twoColumn, + actions: [ + CPTextButton( + title: "Button Title 1", + onPress: () { + print("Button 1"); + } + ), + CPTextButton( + title: "Button Title 2", + onPress: () { + print("Button 2"); + } + ), + ], + informationItems: [ + CPInformationItem(title: "Title", detail: "Detail"), + ] +); + +FlutterCarplay.push(template: informationTemplate, animated: true); +// OR +FlutterCarplay.setRootTemplate(rootTemplate: informationTemplate, animated: true); +// You need to call _flutterCarplay.forceUpdateRootTemplate(); after setting the root template +``` + +You can also update an existing `CPInformationTemplate` without rebuilding the full template. + +```dart +await _flutterCarplay.updateInformationTemplateItems( + elementId: informationTemplate.uniqueId, + items: [ + CPInformationItem(title: "Battery", detail: "85%"), + CPInformationItem(title: "Range", detail: "240 km"), + ], +); + +await _flutterCarplay.updateInformationTemplateActions( + elementId: informationTemplate.uniqueId, + actions: [ + CPTextButton( + title: "Refresh", + onPress: () { + print("Refresh tapped"); + }, + ), + ], +); +``` + +### List Image Row Item + +`CPListImageRowItem` lets you show a row of multiple images inside a `CPListTemplate` section. + +```dart +final CPListTemplate listTemplate = CPListTemplate( + title: "Gallery", + sections: [ + CPListSection( + items: [ + CPListImageRowItem( + text: "Recently played", + gridImages: [ + "https://picsum.photos/200/200?1", + "https://picsum.photos/200/200?2", + "https://picsum.photos/200/200?3", + ], + onPress: (complete, item) { + print(item.text); + complete(); + }, + onItemPress: (complete, item, index) { + print("Tapped image index: $index"); + complete(); + }, + ), + ], + ), + ], +); +``` + +Use `CPListImageRowItem.getMaximumNumberOfGridImages()` if you want to respect the host limit before building the row. + +### Point Of Interest Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/point_of_interest_template.png) + +A Point Of Interest template shows multiple points of interest on a Map +The map section is determined by the points of interest. + +> The Template is limited to 12 Points of Interest. + +```dart + final CPPointOfInterestTemplate pointOfInterestTemplate = + CPPointOfInterestTemplate(title: "Title", poi: [ + CPPointOfInterest( + latitude: 51.5052, + longitude: 7.4938, + title: "Title", + subtitle: "Subtitle", + summary: "Summary", + detailTitle: "DetailTitle", + detailSubtitle: "detailSubtitle", + detailSummary: "detailSummary", + image: "images/logo_flutter_1080px_clr.png", + primaryButton: CPTextButton( + title: "Primary", + onPress: () { + print("Primary button pressed"); + } + ), + secondaryButton: CPTextButton( + title: "Secondary", + onPress: () { + print("Secondary button pressed"); + })) + ]); + + FlutterCarplay.push(template: pointOfInterestTemplate, animated: true); + // OR + FlutterCarplay.setRootTemplate(rootTemplate: pointOfInterestTemplate, animated: true); + // You need to call _flutterCarplay.forceUpdateRootTemplate(); after setting the root template +``` + +### Now Playing Template + +The Now Playing template provides a standardized interface for media playback controls in CarPlay. It uses the system's shared instance and integrates with your app's media session. + +```dart +// Navigate to the Now Playing template +FlutterCarplay.showSharedNowPlaying(animated: true); +``` + +> **Note**: The Now Playing template displays information from your app's active media session. Make sure your app is properly configured with AVAudioSession and media playback controls for the best experience. + +> **Multiple Calls Safe**: The `showSharedNowPlaying()` method can be called multiple times safely without causing issues. + +## Android Auto Templates + +Android Auto templates are built using the [Android for Cars App Library](https://developer.android.com/training/cars/apps). Each template is vehicle-optimized and rendered by the host application on the car screen. + +### Tab Bar Template (Android Auto) + +The Tab Bar Template is a container that displays multiple child templates as tabs. Rendered as `TabTemplate` from the Car App Library. + +> Requires Car App API level 6+. On older hosts, the first tab's content is shown as a plain list. Supports between 2 and 4 tabs — extra tabs beyond the limit are discarded with a warning in logcat. + +```dart +final AATabBarTemplate tabBarTemplate = AATabBarTemplate( + tabs: [ + AAListTemplate( + title: "Home", + tabTitle: "Home", + systemIcon: "house.fill", + sections: [ + AAListSection( + items: [ + AAListItem( + title: "Item 1", + subtitle: "Detail Text", + image: 'images/logo_flutter_1080px_clr.png', + onPress: (complete, self) async { + await Future.delayed(const Duration(seconds: 1)); + complete(); + }, + ), + ], + ), + ], + ), + AAGridTemplate( + title: "Grid", + tabTitle: "Grid", + systemIcon: "square.grid.2x2", + buttons: [ + AAGridButton( + titleVariants: ["Button 1"], + image: 'images/logo_flutter_1080px_clr.png', + onPress: (complete, self) async { + complete(); + }, + ), + ], + ), + ], +); + +await FlutterAndroidAuto.setRootTemplate(template: tabBarTemplate); +``` + +To update the tabs dynamically without resetting the root: + +```dart +tabBarTemplate.updateTabs([/* updated list of AATemplate */]); +await FlutterAndroidAuto.updateTabBarTemplates(template: tabBarTemplate); +``` + +### Grid Template (Android Auto) + +The Grid Template displays a grid of tappable cells, each with an image and a title. Use it to let users select from a fixed set of categories. + +> Android Auto recommends a maximum of 8 buttons per grid. + +```dart +final AAGridTemplate gridTemplate = AAGridTemplate( + title: "Grid Template", + buttons: [ + for (var i = 1; i <= 8; i++) + AAGridButton( + titleVariants: ["Item $i"], + image: 'images/logo_flutter_1080px_clr.png', + loadingMessage: "Loading...", + onPress: (complete, self) async { + await Future.delayed(const Duration(seconds: 1)); + complete(); + }, + ), + ], + emptyViewTitleVariants: ["No items available"], +); + +await FlutterAndroidAuto.push(template: gridTemplate); +// OR +await FlutterAndroidAuto.setRootTemplate(template: gridTemplate); +``` + +### Alert Template (Android Auto) + +Alerts present important information as a full-screen message with one or more action buttons. Because Android Auto does not support true overlay modals, the alert is pushed onto the navigation stack as a `MessageTemplate`. + +> Only one alert can be presented at a time. Use `FlutterAndroidAuto.popModal()` to dismiss it programmatically. + +```dart +final AAAlertTemplate alertTemplate = AAAlertTemplate( + title: "Alert Title", + message: "This is an example message.", + actions: [ + AAAlertAction( + title: "Confirm", + style: AAAlertActionStyle.normal, + onPress: () { + print("Confirm pressed"); + FlutterAndroidAuto.popModal(); + }, + ), + AAAlertAction( + title: "Cancel", + style: AAAlertActionStyle.cancel, + onPress: () { + print("Cancel pressed"); + FlutterAndroidAuto.popModal(); + }, + ), + AAAlertAction( + title: "Delete", + style: AAAlertActionStyle.destructive, + onPress: () { + print("Delete pressed"); + FlutterAndroidAuto.popModal(); + }, + ), + ], + onPresent: (bool completed) { + print("Alert presented: $completed"); + }, +); + +await FlutterAndroidAuto.showAlert(template: alertTemplate); +``` + +### List Template (Android Auto) + +A list presents data as a scrollable, single-column table divided into sections. Each item can include a title, subtitle, and an image. + +```dart +final AAListTemplate listTemplate = AAListTemplate( + title: "Home", + sections: [ + AAListSection( + title: "First Section", + items: [ + AAListItem( + title: "Item 1", + subtitle: "Detail Text", + // Supports three image formats: + // - Asset: 'images/logo.png' + // - File: 'file:///path/to/image.png' + // - URL: 'https://example.com/image.png' + image: 'images/logo_flutter_1080px_clr.png', + loadingMessage: "Loading...", + onPress: (complete, self) async { + await Future.delayed(const Duration(seconds: 1)); + complete(); + }, + ), + AAListItem( + title: "Item 2", + subtitle: "No image example", + onPress: (complete, self) async { + complete(); + }, + ), + ], + ), + ], + emptyViewTitleVariants: ["Nothing here yet"], +); + +await FlutterAndroidAuto.push(template: listTemplate); +// OR +await FlutterAndroidAuto.setRootTemplate(template: listTemplate); +``` + +# Support + +If this package has been helpful, consider supporting its development: + +[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-ea4aaa?logo=github)](https://github.com/sponsors/oguzhnatly) + +Your support helps maintain and improve this package! ❤️ + +# Star History + +[![Star History Chart](https://star-history.dera.page/svg?repos=oguzhnatly/flutter_carplay&type=Date)](https://star-history.dera.page/#oguzhnatly/flutter_carplay&Date) + +# LICENSE + +[**MIT License**](https://github.com/oguzhnatly/flutter_carplay/blob/master/LICENSE) + +Copyright (c) 2021 Oğuzhan Atalay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/flutter_carplay/analysis_options.yaml b/third_party/flutter_carplay/analysis_options.yaml new file mode 100644 index 0000000..e0b5370 --- /dev/null +++ b/third_party/flutter_carplay/analysis_options.yaml @@ -0,0 +1,14 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options + +formatter: + page_width: 80 +linter: + rules: + avoid_redundant_argument_values: true + prefer_final_locals: true + prefer_const_constructors: true + avoid_print: true + prefer_single_quotes: true diff --git a/third_party/flutter_carplay/android/build.gradle b/third_party/flutter_carplay/android/build.gradle new file mode 100644 index 0000000..fbdf051 --- /dev/null +++ b/third_party/flutter_carplay/android/build.gradle @@ -0,0 +1,54 @@ +group = "com.example.flutter_carplay" +version = "1.0-SNAPSHOT" + +buildscript { + ext.kotlin_version = "2.1.0" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.7.3") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +android { + namespace = "com.oguzhnatly.flutter_android_auto" + + compileSdk = 35 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11 + } + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + test.java.srcDirs += "src/test/kotlin" + } + + defaultConfig { + minSdk = 21 + } + + dependencies { + implementation("androidx.car.app:app:1.7.0") + testImplementation("junit:junit:4.13.2") + } +} diff --git a/third_party/flutter_carplay/android/gradle/wrapper/gradle-wrapper.properties b/third_party/flutter_carplay/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b82aa23 --- /dev/null +++ b/third_party/flutter_carplay/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/third_party/flutter_carplay/android/settings.gradle b/third_party/flutter_carplay/android/settings.gradle new file mode 100644 index 0000000..de8e522 --- /dev/null +++ b/third_party/flutter_carplay/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'flutter_carplay' diff --git a/third_party/flutter_carplay/android/src/main/AndroidManifest.xml b/third_party/flutter_carplay/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3ea6bff --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/AndroidAutoService.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/AndroidAutoService.kt new file mode 100644 index 0000000..7d8fe04 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/AndroidAutoService.kt @@ -0,0 +1,103 @@ +package com.oguzhnatly.flutter_android_auto + +import android.content.Context +import androidx.car.app.CarAppService +import androidx.car.app.CarContext +import androidx.car.app.SurfaceCallback +import androidx.car.app.validation.HostValidator +import androidx.car.app.Session +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor +import io.flutter.embedding.engine.FlutterEngineCache; + +/// DELTA B from upstream 1.6.5. +/// +/// Upstream unconditionally builds its own bare `FlutterEngine(this)` when the +/// cache is empty. In an app whose engine owns platform channels — MeshMapper's +/// USB serial and MapLibre tile cache both live on the engine, not on an +/// Activity — that headless engine is the wrong one: its isolate has none of +/// those channels, and if the Activity later builds the real engine the app ends +/// up with two, each with its own copy of every plugin, fighting over this +/// plugin's own static template state. +/// +/// [FAAEngineProvider] lets the host app supply the engine instead. The default +/// path below is unchanged, so apps that do not set a factory behave exactly as +/// they did before. +object FAAEngineProvider { + /// Set this before the car host can start the service — an Application + /// subclass's onCreate is the only reliably early enough place, since + /// Application.onCreate always precedes any Service.onCreate in the process. + /// + /// The factory must return a fully configured engine with Dart already + /// running; this class will not call executeDartEntrypoint on it. + var factory: ((Context) -> FlutterEngine)? = null +} + +/// DELTA D from upstream 1.6.5. +/// +/// A map template draws nothing by itself: the host hands the app a Surface and +/// the app renders onto it. That rendering is entirely app-specific — MeshMapper +/// puts a MapLibre map there — so rather than teach this plugin about any one +/// map SDK, let the host app supply the SurfaceCallback. +/// +/// Leave [factory] null and nothing changes: no surface callback is registered +/// and the plugin behaves exactly as upstream. +object FAASurfaceProvider { + /// Set from the host app before a car session starts. Registering the + /// callback requires the androidx.car.app.ACCESS_SURFACE permission, which + /// is why this is opt-in rather than always-on. + var factory: ((CarContext) -> SurfaceCallback)? = null +} + +class AndroidAutoService : CarAppService() { + companion object { + /// The Android Auto session that this service is handling. + var session: AndroidAutoSession? = null + } + + override fun onCreate() { + super.onCreate() + val engineCache = FlutterEngineCache.getInstance() + val flutterEngineId = FAAConstants.flutterEngineId + + if (engineCache.get(flutterEngineId) != null) return; + + // DELTA B: give the host app first refusal on engine creation. It is + // responsible for having started Dart; we only cache the result under + // the id this plugin looks up. + FAAEngineProvider.factory?.let { create -> + engineCache.put(flutterEngineId, create(this)) + return + } + + // Create new engine in headless mode + val flutterEngine = FlutterEngine(this) + flutterEngine.dartExecutor.executeDartEntrypoint( + DartExecutor.DartEntrypoint.createDefault() + ) + // Cache the engine + engineCache.put(flutterEngineId, flutterEngine) + } + + + /// DELTA C from upstream 1.6.5: upstream returns ALLOW_ALL_HOSTS_VALIDATOR + /// unconditionally. Android's own documentation restricts that to debug + /// builds — it lets *any* app on the device bind this service and drive the + /// car surface — and shipping it is a Play review risk. Validate against the + /// car-app library's bundled allowlist of known hosts in release builds, and + /// keep the permissive behaviour only where the debug flag is set, which is + /// what the Desktop Head Unit needs. + override fun createHostValidator(): HostValidator = + if ((applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0) { + HostValidator.ALLOW_ALL_HOSTS_VALIDATOR + } else { + HostValidator.Builder(applicationContext) + .addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample) + .build() + } + + override fun onCreateSession(): Session { + session = AndroidAutoSession() + return session!! + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAConstants.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAConstants.kt new file mode 100644 index 0000000..cfa64ce --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAConstants.kt @@ -0,0 +1,5 @@ +package com.oguzhnatly.flutter_android_auto + +object FAAConstants { + val flutterEngineId = "android_auto_id" +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAEnums.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAEnums.kt new file mode 100644 index 0000000..9c4eab6 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAEnums.kt @@ -0,0 +1,35 @@ +package com.oguzhnatly.flutter_android_auto + +enum class FAAConnectionTypes { + connected, + background, + disconnected, +} + +enum class FAAChannelTypes { + onAndroidAutoConnectionChange, + setRootTemplate, + forceUpdateRootTemplate, + pushTemplate, + popTemplate, + popToRootTemplate, + updateListTemplateSections, + updatePaneTemplate, + onListItemSelected, + onListItemSelectedComplete, + onListSectionSelected, + onToggleCheckedChange, + onPaneActionPressed, + onMapActionPressed, + onScreenBackButtonPressed, + setAlert, + closePresent, + onAlertActionPressed, + onPresentStateChanged, + updateTabBarTemplates, + onTabBarItemSelected, + onGridButtonPressed, + onGridButtonSelectedComplete, + updateMessageTemplate, + updateLongMessageTemplate, +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAHelpers.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAHelpers.kt new file mode 100644 index 0000000..dc31f1a --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAHelpers.kt @@ -0,0 +1,118 @@ +package com.oguzhnatly.flutter_android_auto + +import android.content.Context +import android.graphics.BitmapFactory +import androidx.car.app.model.CarIcon +import androidx.core.graphics.drawable.IconCompat +import io.flutter.FlutterInjector +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object FAAHelpers { + fun makeFCPChannelId(event: String): String { + return "com.oguzhnatly.flutter_android_auto" + event + } +} + +fun makeCarIconFromBytes(bytes: ByteArray?): CarIcon? { + return makeCarIconFromBytes(bytes, null) +} + +fun makeCarIconFromBytes(bytes: ByteArray?, imageTint: FAAImageTint?): CarIcon? { + if (bytes == null || bytes.isEmpty()) return null + return try { + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null + bitmap.toCarIcon(imageTint) + } catch (e: Exception) { + e.printStackTrace() + null + } +} + +suspend fun loadCarImageFromAsset( + context: Context, + assetPath: String, + imageTint: FAAImageTint? = null, +): CarIcon? { + return withContext(Dispatchers.IO) { + try { + val key = FlutterInjector.instance().flutterLoader() + .getLookupKeyForAsset(assetPath) + context.assets.open(key).use { inputStream -> + val bitmap = BitmapFactory.decodeStream(inputStream) ?: return@use null + bitmap.toCarIcon(imageTint) + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} + +suspend fun loadCarImageFromFile( + path: String, + imageTint: FAAImageTint? = null, +): CarIcon? { + return withContext(Dispatchers.IO) { + try { + val filePath = path.removePrefix("file://") + val file = File(filePath) + if (!file.exists()) return@withContext null + val bitmap = BitmapFactory.decodeFile(filePath) ?: return@withContext null + bitmap.toCarIcon(imageTint) + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} + +suspend fun resolveCarIcon( + context: Context, + bytes: ByteArray?, + imageUrl: String?, + imageTint: FAAImageTint? = null, +): CarIcon? { + makeCarIconFromBytes(bytes, imageTint)?.let { return it } + + val source = imageUrl?.trim() + if (source.isNullOrEmpty()) return null + + return when { + source.startsWith("http") -> loadCarImageAsync(source, imageTint) + source.startsWith("file://") -> loadCarImageFromFile(source, imageTint) + else -> loadCarImageFromAsset(context, source, imageTint) + } +} + +suspend fun loadCarImageAsync( + imageUrl: String, + imageTint: FAAImageTint? = null, +): CarIcon? { + return withContext(Dispatchers.IO) { + try { + val url = URL(imageUrl) + val connection = url.openConnection() as HttpURLConnection + connection.doInput = true + connection.connect() + val inputStream = connection.inputStream + val bitmap = BitmapFactory.decodeStream(inputStream) ?: return@withContext null + bitmap.toCarIcon(imageTint) + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} + +private fun android.graphics.Bitmap.toCarIcon(imageTint: FAAImageTint? = null): CarIcon { + val iconCompat = IconCompat.createWithBitmap(this) + val builder = CarIcon.Builder(iconCompat) + if (imageTint != null) { + builder.setTint(imageTint.toCarColor()) + } + return builder.build() +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FlutterAndroidAutoPlugin.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FlutterAndroidAutoPlugin.kt new file mode 100644 index 0000000..3c101e7 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FlutterAndroidAutoPlugin.kt @@ -0,0 +1,1058 @@ +package com.oguzhnatly.flutter_android_auto + +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.ScreenManager +import androidx.car.app.model.Action +import androidx.car.app.model.CarColor +import androidx.car.app.model.CarIcon +import androidx.car.app.model.CarText +import androidx.car.app.model.GridItem +import androidx.car.app.model.GridTemplate +import androidx.car.app.model.ItemList +import androidx.car.app.model.ListTemplate +import androidx.car.app.model.LongMessageTemplate +import androidx.car.app.model.MessageTemplate +import androidx.car.app.model.Pane +import androidx.car.app.model.PaneTemplate +import androidx.car.app.model.Row +import androidx.car.app.model.SectionedItemList +import androidx.car.app.model.Tab +import androidx.car.app.model.TabContents +import androidx.car.app.model.TabTemplate +import androidx.car.app.model.Template +import androidx.car.app.model.ActionStrip +import androidx.car.app.navigation.model.MapController +import androidx.car.app.navigation.model.MapWithContentTemplate +import androidx.car.app.model.Toggle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +class FlutterAndroidAutoPlugin : FlutterPlugin, EventChannel.StreamHandler { + private val pluginScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + lateinit var channel: MethodChannel + lateinit var eventChannel: EventChannel + + companion object { + var events: EventChannel.EventSink? = null + var currentTemplate: Template? = null + var currentScreen: Screen? = null + var currentAlertScreen: Screen? = null + + private var currentRootTemplateElementId: String? = null + private var currentTabBarData: FAATabBarTemplate? = null + private var activeTabContentId: String? = null + private var pendingTemplateElementId: String? = null + + private val templateDataByElementId = mutableMapOf>() + private val templateRuntimeTypes = mutableMapOf() + private val templateBackButtons = mutableMapOf() + private val templatesByElementId = mutableMapOf() + private val screensByElementId = mutableMapOf() + + fun sendEvent(type: String, data: Map) { + events?.success(mapOf("type" to type, "data" to data)) + } + + fun onAndroidAutoConnectionChange(status: FAAConnectionTypes) { + sendEvent( + type = FAAChannelTypes.onAndroidAutoConnectionChange.name, + data = mapOf("status" to status.name) + ) + } + } + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel( + flutterPluginBinding.binaryMessenger, + FAAHelpers.makeFCPChannelId("") + ) + eventChannel = EventChannel( + flutterPluginBinding.binaryMessenger, + FAAHelpers.makeFCPChannelId("/event") + ) + setUpHandlers() + } + + private fun setUpHandlers() { + channel.setMethodCallHandler { call, result -> + try { + when (call.method) { + FAAChannelTypes.forceUpdateRootTemplate.name -> forceUpdateRootTemplate(call, result) + FAAChannelTypes.setRootTemplate.name -> setRootTemplate(call, result) + FAAChannelTypes.pushTemplate.name -> pushTemplate(call, result) + FAAChannelTypes.popTemplate.name -> popTemplate(call, result) + FAAChannelTypes.popToRootTemplate.name -> popToRootTemplate(call, result) + FAAChannelTypes.updateListTemplateSections.name -> updateListTemplateSections(call, result) + FAAChannelTypes.updatePaneTemplate.name -> updatePaneTemplate(call, result) + FAAChannelTypes.updateMessageTemplate.name -> updateMessageTemplate(call, result) + FAAChannelTypes.updateLongMessageTemplate.name -> updateLongMessageTemplate(call, result) + FAAChannelTypes.onListItemSelectedComplete.name -> onListItemSelectedComplete(call, result) + FAAChannelTypes.onGridButtonSelectedComplete.name -> onGridButtonSelectedComplete(call, result) + FAAChannelTypes.setAlert.name -> setAlert(call, result) + FAAChannelTypes.closePresent.name -> closePresent(call, result) + FAAChannelTypes.updateTabBarTemplates.name -> updateTabBarTemplates(call, result) + else -> result.notImplemented() + } + } catch (e: Exception) { + e.printStackTrace() + result.completeWithError(e) + } + } + eventChannel.setStreamHandler(this) + } + + private fun forceUpdateRootTemplate(call: MethodCall, result: MethodChannel.Result) { + currentScreen?.invalidate() + result.success(true) + } + + private fun popTemplate(call: MethodCall, result: MethodChannel.Result) { + val carContext = AndroidAutoService.session?.carContext + if (carContext == null) { + result.error("No car context", "Android Auto is not connected", null) + return + } + + val screenManager = carContext.getCarService(ScreenManager::class.java) + if (screenManager.stackSize > 1) { + screenManager.pop() + result.success(true) + } else { + result.error("No screens to pop", "You are at root screen", null) + } + } + + private fun popToRootTemplate(call: MethodCall, result: MethodChannel.Result) { + val carContext = AndroidAutoService.session?.carContext + if (carContext == null) { + result.error("No car context", "Android Auto is not connected", null) + return + } + + val screenManager = carContext.getCarService(ScreenManager::class.java) + if (screenManager.stackSize > 1) { + screenManager.popToRoot() + result.success(true) + } else { + result.error("No screens to pop", "You are at root screen", null) + } + } + + private fun onListItemSelectedComplete(call: MethodCall, result: MethodChannel.Result) { + rebuildPendingTemplate(result) + } + + private fun onGridButtonSelectedComplete(call: MethodCall, result: MethodChannel.Result) { + rebuildPendingTemplate(result) + } + + private fun rebuildPendingTemplate(result: MethodChannel.Result) { + val elementId = pendingTemplateElementId + pendingTemplateElementId = null + if (elementId == null) { + result.success(true) + return + } + rebuildElementTemplate(elementId, result) + } + + private fun setAlert(call: MethodCall, result: MethodChannel.Result) { + val carContext = AndroidAutoService.session?.carContext + if (carContext == null) { + result.error("No car context", "Android Auto is not connected", null) + return + } + + val data = call.argument>("template") + if (data == null) { + result.error("Missing template", "template argument is required", null) + return + } + + pluginScope.launchMethodCall(result) { + val alertTemplate = FAAAlertTemplate.fromJson(data) + val messageTemplate = buildAlertMessageTemplate(alertTemplate) + + val alertScreen = object : Screen(carContext) { + override fun onGetTemplate(): Template = messageTemplate + + init { + lifecycle.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) { + currentAlertScreen = null + sendEvent( + type = FAAChannelTypes.onPresentStateChanged.name, + data = mapOf( + "elementId" to alertTemplate.elementId, + "completed" to false, + ) + ) + } + } + }) + } + } + + currentAlertScreen = alertScreen + carContext.getCarService(ScreenManager::class.java).push(alertScreen) + true + } + } + + private fun buildAlertMessageTemplate(alert: FAAAlertTemplate): Template { + val body = alert.message?.takeIf { it.isNotBlank() } ?: " " + val title = alert.title.takeIf { it.isNotBlank() } ?: " " + val builder = MessageTemplate.Builder(body).setTitle(title) + + for (action in alert.actions) { + val actionBuilder = Action.Builder() + .setTitle(action.title) + .setOnClickListener { + sendEvent( + type = FAAChannelTypes.onAlertActionPressed.name, + data = mapOf("elementId" to action.elementId) + ) + } + + if (action.style == "destructive") { + actionBuilder.setBackgroundColor(CarColor.RED) + } + + builder.addAction(actionBuilder.build()) + } + return builder.build() + } + + private fun closePresent(call: MethodCall, result: MethodChannel.Result) { + val carContext = AndroidAutoService.session?.carContext + if (carContext == null) { + result.error("No car context", "Android Auto is not connected", null) + return + } + val alertScreen = currentAlertScreen + if (alertScreen == null) { + result.error("No modal", "No modal template is currently presented", null) + return + } + carContext.getCarService(ScreenManager::class.java).pop() + currentAlertScreen = null + result.success(true) + } + + private fun updateTabBarTemplates(call: MethodCall, result: MethodChannel.Result) { + val data = call.argument>("template") + if (data == null) { + result.error("Missing template", "template argument is required", null) + return + } + val tabBarTemplate = try { + FAATabBarTemplate.fromJson(data) + } catch (e: IllegalArgumentException) { + result.error("Invalid template", e.message, null) + return + } + + pluginScope.launchMethodCall(result) { + currentTabBarData = tabBarTemplate + storeTemplateData(tabBarTemplate.elementId, "FAATabBarTemplate", data, false, currentScreen) + storeTabData(tabBarTemplate) + if (tabBarTemplate.tabs.none { it.elementId == activeTabContentId }) { + activeTabContentId = tabBarTemplate.tabs.firstOrNull()?.elementId + } + currentTemplate = buildNativeTabTemplate(tabBarTemplate) + currentScreen?.invalidate() + true + } + } + + private fun updateListTemplateSections(call: MethodCall, result: MethodChannel.Result) { + val elementId = call.argument("elementId") ?: "" + val sections = call.argument>>("sections") ?: emptyList() + val data = templateDataByElementId[elementId] + if (data == null) { + result.error("No template found", "AAListTemplate not found with elementId: $elementId", null) + return + } + val parsedSections = sections.map { FAAListSection.fromJson(it) } + val hasSelectableList = parsedSections.any { it.isSelectable } + val isSingleUntitledList = parsedSections.size == 1 && parsedSections.first().title.isEmpty() + if (hasSelectableList && !isSingleUntitledList) { + result.error( + "Invalid template", + "A selectable AAListSection must be the only section in an AAListTemplate and must not have a title.", + null, + ) + return + } + + data["sections"] = sections + rebuildElementTemplate(elementId, result) + } + + private fun updatePaneTemplate(call: MethodCall, result: MethodChannel.Result) { + val data = call.argument>("template") + if (data == null) { + result.error("Missing template", "A pane template payload is required", null) + return + } + val elementId = data["_elementId"] as? String ?: "" + if (elementId.isEmpty()) { + result.error("Missing elementId", "The pane template must have an element id", null) + return + } + + storeTemplateData(elementId, "FAAPaneTemplate", data, templateBackButtons[elementId] ?: true, screensByElementId[elementId]) + rebuildElementTemplate(elementId, result) + } + + private fun updateMessageTemplate(call: MethodCall, result: MethodChannel.Result) { + updateMessageTemplate(call, result, "message", "FAAMessageTemplate") + } + + private fun updateLongMessageTemplate(call: MethodCall, result: MethodChannel.Result) { + updateMessageTemplate(call, result, "long message", "FAALongMessageTemplate") + } + + private fun updateMessageTemplate( + call: MethodCall, + result: MethodChannel.Result, + templateType: String, + runtimeType: String, + ) { + val elementId = call.argument("elementId") ?: "" + if (elementId.isEmpty()) { + result.error("Missing elementId", "elementId is required to update a $templateType template", null) + return + } + + val data = mutableMapOf( + "_elementId" to elementId, + "title" to (call.argument("title") ?: ""), + "message" to (call.argument("message") ?: ""), + ) + storeTemplateData(elementId, runtimeType, data, templateBackButtons[elementId] ?: true, screensByElementId[elementId]) + rebuildElementTemplate(elementId, result) + } + + private fun pushTemplate(call: MethodCall, result: MethodChannel.Result) { + val carContext = AndroidAutoService.session?.carContext + if (carContext == null) { + result.error("No car context", "Android Auto is not connected", null) + return + } + + val runtimeType = call.argument("runtimeType") ?: "" + val data = call.argument>("template") + if (data == null) { + result.error("Missing template", "template argument is required", null) + return + } + val elementId = data["_elementId"] as? String ?: "" + + pluginScope.launchMethodCall(result) { + val newScreen = object : Screen(carContext) { + override fun onGetTemplate(): Template = templatesByElementId[elementId] + ?: getTemplateBlocking(runtimeType, data, true, this) + + init { + lifecycle.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) { + removeTemplateData(elementId) + sendEvent( + type = FAAChannelTypes.onScreenBackButtonPressed.name, + data = mapOf("elementId" to elementId) + ) + } + } + }) + } + } + + val template = buildTemplateForType(runtimeType, data, true, newScreen) + + storeTemplateData(elementId, runtimeType, data, true, newScreen) + templatesByElementId[elementId] = template + carContext.getCarService(ScreenManager::class.java).push(newScreen) + true + } + } + + private fun setRootTemplate(call: MethodCall, result: MethodChannel.Result) { + val runtimeType = call.argument("runtimeType") ?: "" + val data = call.argument>("template") + if (data == null) { + result.error("Missing template", "template argument is required", null) + return + } + val elementId = data["_elementId"] as? String ?: "" + + pluginScope.launchMethodCall(result) { + val template = buildTemplateForType(runtimeType, data, false, currentScreen) + + currentRootTemplateElementId = elementId + currentTemplate = template + storeTemplateData(elementId, runtimeType, data, false, currentScreen) + templatesByElementId[elementId] = template + currentScreen?.invalidate() + true + } + } + + private fun rebuildElementTemplate(elementId: String, result: MethodChannel.Result) { + val runtimeType = templateRuntimeTypes[elementId] + val data = templateDataByElementId[elementId] + if (runtimeType == null || data == null) { + result.error("No template found", "No Android Auto template found with elementId: $elementId", null) + return + } + + pluginScope.launchMethodCall(result) { + val template = if (currentTabBarData != null && currentTabBarData!!.tabs.any { it.elementId == elementId }) { + buildNativeTabTemplate(currentTabBarData!!) + } else { + buildTemplateForType( + runtimeType, + data, + templateBackButtons[elementId] ?: true, + screensByElementId[elementId], + ) + } + + if (currentTabBarData != null && currentTabBarData!!.tabs.any { it.elementId == elementId }) { + currentTemplate = template + currentScreen?.invalidate() + } else { + templatesByElementId[elementId] = template + if (currentRootTemplateElementId == elementId) { + currentTemplate = template + currentScreen?.invalidate() + } else { + screensByElementId[elementId]?.invalidate() + } + } + true + } + } + + private fun storeTemplateData( + elementId: String, + runtimeType: String, + data: Map, + addBackButton: Boolean, + screen: Screen?, + ) { + if (elementId.isEmpty()) return + templateDataByElementId[elementId] = data.toMutableMap() + templateRuntimeTypes[elementId] = runtimeType + templateBackButtons[elementId] = addBackButton + if (screen != null) screensByElementId[elementId] = screen + } + + private fun removeTemplateData(elementId: String) { + templateDataByElementId.remove(elementId) + templateRuntimeTypes.remove(elementId) + templateBackButtons.remove(elementId) + templatesByElementId.remove(elementId) + screensByElementId.remove(elementId) + } + + private fun storeTabData(tabBar: FAATabBarTemplate) { + for (tab in tabBar.tabs) { + storeTemplateData(tab.elementId, tab.runtimeType, tab.templateData, false, currentScreen) + } + } + + private fun getTemplateBlocking( + runtimeType: String, + data: Map, + addBackButton: Boolean, + owningScreen: Screen?, + ): Template = kotlinx.coroutines.runBlocking { + buildTemplateForType(runtimeType, data, addBackButton, owningScreen) + } + + private suspend fun buildTemplateForType( + runtimeType: String, + data: Map, + addBackButton: Boolean = true, + owningScreen: Screen? = null, + ): Template = when (runtimeType) { + "FAAListTemplate" -> getListTemplate(data, addBackButton, owningScreen) + "FAAGridTemplate" -> getGridTemplate(data, addBackButton, owningScreen) + "FAATabBarTemplate" -> { + val tabBarTemplate = FAATabBarTemplate.fromJson(data) + currentTabBarData = tabBarTemplate + storeTabData(tabBarTemplate) + if (activeTabContentId == null || tabBarTemplate.tabs.none { it.elementId == activeTabContentId }) { + activeTabContentId = tabBarTemplate.tabs.firstOrNull()?.elementId + } + buildNativeTabTemplate(tabBarTemplate) + } + "FAAPaneTemplate" -> getPaneTemplate(data, addBackButton) + // DELTA D: see FAASurfaceProvider in AndroidAutoService.kt. + "FAAMapWithContentTemplate" -> + getMapWithContentTemplate(data, addBackButton, owningScreen) + "FAAMessageTemplate" -> getMessageTemplate(data, addBackButton) + "FAALongMessageTemplate" -> getLongMessageTemplate(data, addBackButton) + else -> throw IllegalArgumentException("Template type $runtimeType is not supported") + } + + /// DELTA D from upstream 1.6.5: MapWithContentTemplate support. + /// + /// The host draws nothing here — the app does, onto a Surface it gets from + /// AppManager.setSurfaceCallback. All this builds is the frame: a map region + /// the app renders into, plus whatever content template Dart nested inside + /// it, which goes through the same dispatch so any supported template works + /// as the content half. + /// + /// Requires car API level 7 and the androidx.car.app.MAP_TEMPLATES and + /// androidx.car.app.ACCESS_SURFACE permissions, and an app category that is + /// permitted to draw maps (navigation, POI, or weather — not IOT). + private suspend fun getMapWithContentTemplate( + data: Map, + addBackButton: Boolean, + owningScreen: Screen?, + ): Template { + val contentType = data["contentRuntimeType"] as? String + ?: throw IllegalArgumentException("contentRuntimeType is required") + val contentData = data["contentTemplate"] as? Map + ?: throw IllegalArgumentException("contentTemplate is required") + + val content = buildTemplateForType(contentType, contentData, addBackButton, owningScreen) + val builder = MapWithContentTemplate.Builder().setContentTemplate(content) + + @Suppress("UNCHECKED_CAST") + val actions = (data["mapActions"] as? List>).orEmpty() + if (actions.isNotEmpty()) { + val strip = ActionStrip.Builder() + for (action in actions) { + strip.addAction(createMapAction(currentScreen?.carContext, action)) + } + builder.setMapController( + MapController.Builder().setMapActionStrip(strip.build()).build(), + ) + } + return builder.build() + } + + /// An action for the vertical strip the host draws down the right edge of + /// the map. + /// + /// Deliberately icon-only: ACTIONS_CONSTRAINTS_MAP leaves maxCustomTitles at + /// zero, so setting a title here gets the whole template rejected. The title + /// still travels from Dart and is used only as the content description. + private suspend fun createMapAction( + carContext: CarContext?, + action: Map, + ): Action { + val elementId = action["_elementId"] as? String ?: "" + val imageUrl = action["imageUrl"] as? String + val builder = Action.Builder() + + val icon = if (carContext != null && imageUrl != null) { + resolveCarIcon(carContext, null, imageUrl, null) + } else null + if (icon != null) builder.setIcon(icon) + + if (action["isPrimary"] == true) builder.setFlags(Action.FLAG_PRIMARY) + if (action["onPress"] == true) { + builder.setOnClickListener { + sendEvent( + type = FAAChannelTypes.onMapActionPressed.name, + data = mapOf("elementId" to elementId), + ) + } + } + return builder.build() + } + + private suspend fun buildNativeTabTemplate(tabBar: FAATabBarTemplate): Template { + val activeId = activeTabContentId ?: tabBar.tabs.firstOrNull()?.elementId + val activeTab = tabBar.tabs.find { it.elementId == activeId } + ?: tabBar.tabs.firstOrNull() + ?: return ListTemplate.Builder().setLoading(true).build() + + val carContext = AndroidAutoService.session?.carContext + val supportsTabTemplate = carContext != null && carContext.getCarAppApiLevel() >= 6 + + if (tabBar.tabs.size < 2 || !supportsTabTemplate) { + return buildInnerTemplateForTab(activeTab, false) + } + + val cappedTabs = tabBar.tabs.take(4) + val resolvedActiveTab = if (cappedTabs.contains(activeTab)) activeTab else cappedTabs.first() + + val tabCallback = object : TabTemplate.TabCallback { + override fun onTabSelected(tabContentId: String) { + pluginScope.launch { + activeTabContentId = tabContentId + currentTabBarData?.let { + currentTemplate = buildNativeTabTemplate(it) + currentScreen?.invalidate() + } + sendEvent( + type = FAAChannelTypes.onTabBarItemSelected.name, + data = mapOf("elementId" to tabContentId) + ) + } + } + } + + val builder = TabTemplate.Builder(tabCallback) + builder.setHeaderAction(Action.APP_ICON) + builder.setActiveTabContentId(resolvedActiveTab.elementId) + + for (tab in cappedTabs) { + builder.addTab( + Tab.Builder() + .setTitle(resolveTabTitle(tab)) + .setIcon(resolveTabIcon(carContext, tab)) + .setContentId(tab.elementId) + .build() + ) + } + + val innerTemplate = buildInnerTemplateForTab(resolvedActiveTab, false) + builder.setTabContents(TabContents.Builder(innerTemplate).build()) + return builder.build() + } + + private suspend fun buildInnerTemplateForTab( + tab: FAATabBarItem, + addBackButton: Boolean, + ): Template { + val data = templateDataByElementId[tab.elementId] ?: tab.templateData + val runtimeType = templateRuntimeTypes[tab.elementId] ?: tab.runtimeType + return buildTemplateForType(runtimeType, data, addBackButton, currentScreen) + } + + private fun resolveTabTitle(tab: FAATabBarItem): String { + val data = templateDataByElementId[tab.elementId] ?: tab.templateData + return data["tabTitle"] as? String ?: data["title"] as? String ?: tab.tabTitle + } + + private suspend fun resolveTabIcon(carContext: CarContext?, tab: FAATabBarItem): CarIcon { + val data = templateDataByElementId[tab.elementId] ?: tab.templateData + val iconUrl = data["iconUrl"] as? String ?: tab.iconUrl + if (carContext != null && !iconUrl.isNullOrBlank()) { + resolveCarIcon(carContext, null, iconUrl)?.let { return it } + } + + val systemIcon = data["systemIcon"] as? String ?: tab.systemIcon + if (carContext != null && !systemIcon.isNullOrBlank()) { + val value = systemIcon.trim() + if (value.startsWith("http") || value.startsWith("file://") || value.contains("/") || value.contains(".")) { + resolveCarIcon(carContext, null, value)?.let { return it } + } + } + + return when (systemIcon?.lowercase()) { + "map", "map.fill", "location", "location.fill", "navigation", "navigation.fill", + "location.north", "location.north.fill" -> CarIcon.PAN + "exclamationmark", "exclamationmark.triangle", "exclamationmark.triangle.fill", + "alert", "bell", "bell.fill" -> CarIcon.ALERT + "pencil", "pencil.circle", "compose", "square.and.pencil", "message", + "message.fill", "bubble.left", "bubble.right" -> CarIcon.COMPOSE_MESSAGE + "chevron.backward", "chevron.left", "arrow.backward", "back", "arrow.left", + "arrowshape.backward", "arrowshape.backward.fill" -> CarIcon.BACK + "xmark.circle", "xmark", "multiply", "error", "exclamationmark.circle" -> CarIcon.ERROR + "hand.draw", "hand.point.up", "pan" -> CarIcon.PAN + else -> CarIcon.COMPOSE_MESSAGE + } + } + + private fun getMessageTemplate( + data: Map, + addBackButton: Boolean = true, + ): Template = buildMessageTemplate( + data, + addBackButton, + createBuilder = { MessageTemplate.Builder(it) }, + setTitle = { title -> setTitle(title) }, + setHeaderAction = { action -> setHeaderAction(action) }, + build = { build() }, + ) + + private fun getLongMessageTemplate( + data: Map, + addBackButton: Boolean = true, + ): Template = buildMessageTemplate( + data, + addBackButton, + createBuilder = { LongMessageTemplate.Builder(it) }, + setTitle = { title -> setTitle(title) }, + setHeaderAction = { action -> setHeaderAction(action) }, + build = { build() }, + ) + + private fun buildMessageTemplate( + data: Map, + addBackButton: Boolean, + createBuilder: (String) -> Builder, + setTitle: Builder.(String) -> Unit, + setHeaderAction: Builder.(Action) -> Unit, + build: Builder.() -> Template, + ): Template { + val template = FAAMessageTemplate.fromJson(data) + val builder = createBuilder(template.message) + builder.setTitle(template.title) + if (addBackButton) builder.setHeaderAction(Action.BACK) + return builder.build() + } + + private suspend fun getPaneTemplate( + data: Map, + addBackButton: Boolean = true, + ): Template { + val carContext = AndroidAutoService.session?.carContext + val template = FAAPaneTemplate.fromJson(data) + val paneBuilder = Pane.Builder() + + val isLoading = template.isLoading || template.items.isEmpty() + paneBuilder.setLoading(isLoading) + if (!isLoading) { + for (item in template.items) { + paneBuilder.addRow(createPaneRowFromItem(carContext, item)) + } + + val imageIcon = makeCarIconFromBytes(template.imageData, template.imageTint) + ?: if (carContext != null && template.imageUrl != null) { + resolveCarIcon(carContext, null, template.imageUrl, template.imageTint) + } else null + if (imageIcon != null) paneBuilder.setImage(imageIcon) + + for (action in template.actions) { + paneBuilder.addAction(createPaneAction(carContext, action)) + } + } + + // DELTA D: an empty title means no header. build() does not require one, + // and setting an empty CarText would draw an empty header bar rather + // than none at all. + val paneTemplateBuilder = PaneTemplate.Builder(paneBuilder.build()) + if (template.title.isNotEmpty()) paneTemplateBuilder.setTitle(template.title) + if (addBackButton) paneTemplateBuilder.setHeaderAction(Action.BACK) + return paneTemplateBuilder.build() + } + + private suspend fun createPaneRowFromItem( + carContext: CarContext?, + item: FAAPaneItem, + ): Row { + val rowBuilder = Row.Builder().setTitle(CarText.create(item.title)) + item.detail?.let { rowBuilder.addText(CarText.create(it)) } + + val imageIcon = makeCarIconFromBytes(item.imageData, item.imageTint) + ?: if (carContext != null && item.imageUrl != null) { + resolveCarIcon(carContext, null, item.imageUrl, item.imageTint) + } else null + if (imageIcon != null) { + rowBuilder.setImage( + imageIcon, + if (item.imageTint != null) Row.IMAGE_TYPE_ICON else Row.IMAGE_TYPE_SMALL, + ) + } + return rowBuilder.build() + } + + private suspend fun createPaneAction( + carContext: CarContext?, + action: FAAPaneAction, + ): Action { + val actionBuilder = Action.Builder().setTitle(action.title) + val imageIcon = makeCarIconFromBytes(action.imageData, action.imageTint) + ?: if (carContext != null && action.imageUrl != null) { + resolveCarIcon(carContext, null, action.imageUrl, action.imageTint) + } else null + if (imageIcon != null) actionBuilder.setIcon(imageIcon) + if (action.isPrimary) actionBuilder.setFlags(Action.FLAG_PRIMARY) + if (action.isOnPressListenerActive) { + actionBuilder.setOnClickListener { + sendEvent( + type = FAAChannelTypes.onPaneActionPressed.name, + data = mapOf("elementId" to action.elementId), + ) + } + } + return actionBuilder.build() + } + + private suspend fun getListTemplate( + data: Map, + addBackButton: Boolean = true, + owningScreen: Screen? = null, + ): Template { + val carContext = AndroidAutoService.session?.carContext + val template = FAAListTemplate.fromJson(data) + val builder = ListTemplate.Builder().setTitle(template.title) + val emptyMessage = template.emptyViewTitleVariants.firstOrNull() + val isEmpty = template.sections.isEmpty() || template.sections.all { it.items.isEmpty() } + + if (isEmpty) { + if (emptyMessage != null) { + builder.setLoading(false) + builder.setSingleList(ItemList.Builder().setNoItemsMessage(emptyMessage).build()) + } else { + builder.setLoading(true) + } + } else { + builder.setLoading(false) + val isSingleList = template.sections.size == 1 && template.sections.first().title.isEmpty() + if (isSingleList) { + builder.setSingleList( + createItemListFromSection(carContext, template.sections.first(), template.elementId, "FAAListTemplate", owningScreen) + ) + } else { + for (section in template.sections) { + builder.addSectionedList( + SectionedItemList.create( + createItemListFromSection(carContext, section, template.elementId, "FAAListTemplate", owningScreen), + section.title, + ) + ) + } + } + } + + if (addBackButton) builder.setHeaderAction(Action.BACK) + return builder.build() + } + + private suspend fun createItemListFromSection( + carContext: CarContext?, + section: FAAListSection, + templateElementId: String, + runtimeType: String, + owningScreen: Screen?, + ): ItemList { + val itemListBuilder = ItemList.Builder() + val useSelectionListener = section.isOnSelectedListenerActive || section.selectedIndex != null + + for (item in section.items) { + itemListBuilder.addItem( + createRowFromItem( + carContext, + item, + templateElementId, + runtimeType, + owningScreen, + enableOnClick = !useSelectionListener, + ) + ) + } + + if (useSelectionListener) { + itemListBuilder.setOnSelectedListener { selectedIndex -> + if (section.isOnSelectedListenerActive) { + sendEvent( + type = FAAChannelTypes.onListSectionSelected.name, + data = mapOf( + "elementId" to section.elementId, + "selectedIndex" to selectedIndex, + ) + ) + } + } + } + + section.selectedIndex?.let { selectedIndex -> + if (selectedIndex >= 0 && selectedIndex < section.items.size) { + itemListBuilder.setSelectedIndex(selectedIndex) + } + } + + return itemListBuilder.build() + } + + private suspend fun createRowFromItem( + carContext: CarContext?, + item: FAAListItem, + templateElementId: String, + runtimeType: String, + owningScreen: Screen?, + enableOnClick: Boolean = true, + ): Row { + val rowBuilder = Row.Builder().setTitle(CarText.create(item.title)) + item.subtitle?.let { rowBuilder.addText(CarText.create(it)) } + + val imageIcon = makeCarIconFromBytes(item.imageData, item.imageTint) + ?: if (carContext != null && item.imageUrl != null) { + resolveCarIcon(carContext, null, item.imageUrl, item.imageTint) + } else null + if (imageIcon != null) { + rowBuilder.setImage( + imageIcon, + if (item.imageTint != null) Row.IMAGE_TYPE_ICON else Row.IMAGE_TYPE_SMALL, + ) + } + + val trailingIcon = makeCarIconFromBytes(item.trailingImageData, item.trailingImageTint) + ?: if (carContext != null && item.trailingImage != null) { + resolveCarIcon(carContext, null, item.trailingImage, item.trailingImageTint) + } else null + if (trailingIcon != null) { + rowBuilder.addAction(Action.Builder().setIcon(trailingIcon).build()) + } + + item.isBrowsable?.let { rowBuilder.setBrowsable(it) } + + item.toggle?.let { toggle -> + val toggleBuilder = Toggle.Builder { checked -> + if (toggle.isOnCheckedChangeListenerActive) { + sendEvent( + type = FAAChannelTypes.onToggleCheckedChange.name, + data = mapOf( + "elementId" to item.elementId, + "checked" to checked, + ) + ) + } + }.setChecked(toggle.isChecked) + toggle.isEnabled?.let { toggleBuilder.setEnabled(it) } + rowBuilder.setToggle(toggleBuilder.build()) + } + + if (enableOnClick && item.isOnPressListenerActive) { + rowBuilder.setOnClickListener { + showLoadingForTemplate(templateElementId, runtimeType, item.loadingMessage) + sendEvent( + type = FAAChannelTypes.onListItemSelected.name, + data = mapOf("elementId" to item.elementId) + ) + } + } + return rowBuilder.build() + } + + private suspend fun getGridTemplate( + data: Map, + addBackButton: Boolean = true, + owningScreen: Screen? = null, + ): Template { + val carContext = AndroidAutoService.session?.carContext + val template = FAAGridTemplate.fromJson(data) + val builder = GridTemplate.Builder().setTitle(template.title) + val emptyMessage = template.emptyViewTitleVariants.firstOrNull() + + if (template.buttons.isEmpty()) { + if (emptyMessage != null) { + builder.setLoading(false) + builder.setSingleList(ItemList.Builder().setNoItemsMessage(emptyMessage).build()) + } else { + builder.setLoading(true) + } + } else { + builder.setLoading(false) + val itemListBuilder = ItemList.Builder() + for (button in template.buttons) { + itemListBuilder.addItem( + createGridItemFromButton(carContext, button, template.elementId, "FAAGridTemplate", owningScreen) + ) + } + builder.setSingleList(itemListBuilder.build()) + } + + if (addBackButton) builder.setHeaderAction(Action.BACK) + return builder.build() + } + + private suspend fun createGridItemFromButton( + carContext: CarContext?, + button: FAAGridButton, + templateElementId: String, + runtimeType: String, + owningScreen: Screen?, + ): GridItem { + val itemBuilder = GridItem.Builder().setTitle(button.title) + val carIcon = makeCarIconFromBytes(button.imageData) + ?: if (carContext != null && button.image != null) { + resolveCarIcon(carContext, null, button.image) + } else null + + itemBuilder.setImage(carIcon ?: CarIcon.COMPOSE_MESSAGE) + + if (button.isOnPressListenerActive) { + itemBuilder.setOnClickListener { + showLoadingForTemplate(templateElementId, runtimeType, button.loadingMessage) + sendEvent( + type = FAAChannelTypes.onGridButtonPressed.name, + data = mapOf("elementId" to button.elementId) + ) + } + } + return itemBuilder.build() + } + + private fun showLoadingForTemplate( + templateElementId: String, + runtimeType: String, + loadingMessage: String? = null, + ) { + pendingTemplateElementId = templateElementId + val loading = buildLoadingTemplate(runtimeType, loadingMessage, templateBackButtons[templateElementId] ?: false) + + if (currentTabBarData != null && currentTabBarData!!.tabs.any { it.elementId == templateElementId }) { + currentTemplate = loading + currentScreen?.invalidate() + return + } + + templatesByElementId[templateElementId] = loading + if (currentRootTemplateElementId == templateElementId) { + currentTemplate = loading + currentScreen?.invalidate() + } else { + screensByElementId[templateElementId]?.invalidate() + } + } + + private fun buildLoadingTemplate( + runtimeType: String, + loadingMessage: String?, + addBackButton: Boolean, + ): Template { + return if (runtimeType == "FAAGridTemplate") { + GridTemplate.Builder() + .setLoading(true) + .apply { + if (!loadingMessage.isNullOrBlank()) setTitle(loadingMessage) + if (addBackButton) setHeaderAction(Action.BACK) + } + .build() + } else { + ListTemplate.Builder() + .setLoading(true) + .apply { + if (!loadingMessage.isNullOrBlank()) setTitle(loadingMessage) + if (addBackButton) setHeaderAction(Action.BACK) + } + .build() + } + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + FlutterAndroidAutoPlugin.events = events + } + + override fun onCancel(arguments: Any?) { + events?.endOfStream() + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResult.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResult.kt new file mode 100644 index 0000000..8c6e6da --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResult.kt @@ -0,0 +1,35 @@ +package com.oguzhnatly.flutter_android_auto + +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +private const val METHOD_CALL_ERROR_CODE = "android_auto_error" +private const val METHOD_CALL_CANCELLED_CODE = "operation_cancelled" + +internal fun CoroutineScope.launchMethodCall( + result: MethodChannel.Result, + block: suspend () -> T, +): Job = launch { + val value = try { + block() + } catch (exception: CancellationException) { + result.error(METHOD_CALL_CANCELLED_CODE, exception.message, null) + throw exception + } catch (exception: Exception) { + result.completeWithError(exception) + return@launch + } + + result.success(value) +} + +internal fun MethodChannel.Result.completeWithError(exception: Exception) { + error( + METHOD_CALL_ERROR_CODE, + exception.message ?: exception.javaClass.simpleName, + exception.stackTraceToString(), + ) +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Screen.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Screen.kt new file mode 100644 index 0000000..f2058b6 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Screen.kt @@ -0,0 +1,36 @@ +package com.oguzhnatly.flutter_android_auto + + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.service.credentials.Action +import android.text.Spannable +import android.text.SpannableString +import androidx.annotation.OptIn +import androidx.car.app.CarAppService +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.Session +import androidx.car.app.annotations.ExperimentalCarApi +import androidx.car.app.model.CarLocation +import androidx.car.app.model.ItemList +import androidx.car.app.model.Metadata +import androidx.car.app.model.PlaceListMapTemplate +import androidx.car.app.model.PlaceMarker +import androidx.car.app.model.Row +import androidx.car.app.model.Template +import androidx.car.app.model.ListTemplate + +class MainScreen(carContext: CarContext) : Screen(carContext) { + init {} + + override fun onGetTemplate(): Template { + val appName = + carContext.applicationInfo.loadLabel(carContext.packageManager) + .toString() ?: "" + + return FlutterAndroidAutoPlugin.currentTemplate + ?: ListTemplate.Builder().setTitle(appName).setLoading(true).build() + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Session.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Session.kt new file mode 100644 index 0000000..f73a91d --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Session.kt @@ -0,0 +1,49 @@ +package com.oguzhnatly.flutter_android_auto + +import android.content.Intent +import androidx.car.app.AppManager +import androidx.car.app.Screen +import androidx.car.app.Session +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner + +class AndroidAutoSession : Session() { + override fun onCreateScreen(intent: Intent): Screen { + val screen = MainScreen(carContext) + FlutterAndroidAutoPlugin.currentScreen = screen + + // DELTA D: hand the host app the CarContext so it can render onto the + // car's Surface. Opt-in — apps that set no factory never touch + // AppManager, and so never need the ACCESS_SURFACE permission. + FAASurfaceProvider.factory?.let { create -> + carContext.getCarService(AppManager::class.java) + .setSurfaceCallback(create(carContext)) + } + + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + FlutterAndroidAutoPlugin.onAndroidAutoConnectionChange( + FAAConnectionTypes.connected + ) + super.onStart(owner) + } + + + override fun onResume(owner: LifecycleOwner) { + FlutterAndroidAutoPlugin.onAndroidAutoConnectionChange( + FAAConnectionTypes.connected + ) + super.onResume(owner) + } + + override fun onStop(owner: LifecycleOwner) { + FlutterAndroidAutoPlugin.onAndroidAutoConnectionChange( + FAAConnectionTypes.disconnected + ) + super.onStop(owner) + } + }) + + return screen + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertAction.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertAction.kt new file mode 100644 index 0000000..5a3167c --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertAction.kt @@ -0,0 +1,16 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAAlertAction( + val elementId: String, + val title: String, + val style: String, +) { + companion object { + fun fromJson(map: Map): FAAAlertAction { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val style = map["style"] as? String ?: "normal" + return FAAAlertAction(elementId, title, style) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertTemplate.kt new file mode 100644 index 0000000..7f713b0 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertTemplate.kt @@ -0,0 +1,21 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAAlertTemplate( + val elementId: String, + val title: String, + val message: String?, + val actions: List, +) { + companion object { + fun fromJson(map: Map): FAAAlertTemplate { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val message = map["message"] as? String + val actions = (map["actions"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAAAlertAction.fromJson(it) } + } ?: emptyList() + return FAAAlertTemplate(elementId, title, message, actions) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/common/FAAImageTint.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/common/FAAImageTint.kt new file mode 100644 index 0000000..986a6df --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/common/FAAImageTint.kt @@ -0,0 +1,78 @@ +package com.oguzhnatly.flutter_android_auto + +import android.graphics.Color +import androidx.car.app.model.CarColor +import kotlin.math.roundToInt + +data class FAAImageTint( + val type: String, + val color: FAAColor? = null, + val darkColor: FAAColor? = null, + val selectedSafe: Boolean = true, +) { + fun toCarColor(): CarColor { + return when (type) { + "platform" -> CarColor.DEFAULT + "primary" -> CarColor.PRIMARY + "secondary" -> CarColor.SECONDARY + "red" -> CarColor.RED + "green" -> CarColor.GREEN + "blue" -> CarColor.BLUE + "yellow" -> CarColor.YELLOW + "custom" -> { + val lightColor = color?.toColorInt() ?: Color.WHITE + val darkColor = darkColor?.toColorInt() ?: lightColor + CarColor.createCustom(lightColor, darkColor) + } + else -> CarColor.DEFAULT + } + } + + companion object { + fun fromJson(map: Map?): FAAImageTint? { + if (map == null) return null + val type = map["type"] as? String ?: return null + return FAAImageTint( + type = type, + color = FAAColor.fromJson(map["color"] as? Map), + darkColor = FAAColor.fromJson(map["darkColor"] as? Map), + selectedSafe = map["selectedSafe"] as? Boolean ?: true, + ) + } + } +} + +data class FAAColor( + val red: Int, + val green: Int, + val blue: Int, + val alpha: Double = 1.0, +) { + fun toColorInt(): Int { + val alphaInt = if (alpha > 1) alpha.roundToInt() else (alpha * 255).roundToInt() + return Color.argb( + alphaInt.coerceIn(0, 255), + red.coerceIn(0, 255), + green.coerceIn(0, 255), + blue.coerceIn(0, 255), + ) + } + + companion object { + fun fromJson(map: Map?): FAAColor? { + if (map == null) return null + val red = numberValue(map["red"])?.roundToInt() ?: return null + val green = numberValue(map["green"])?.roundToInt() ?: return null + val blue = numberValue(map["blue"])?.roundToInt() ?: return null + val alpha = numberValue(map["alpha"]) ?: 1.0 + return FAAColor(red, green, blue, alpha) + } + + private fun numberValue(value: Any?): Double? { + return when (value) { + is Number -> value.toDouble() + else -> null + } + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridButton.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridButton.kt new file mode 100644 index 0000000..c9996a8 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridButton.kt @@ -0,0 +1,33 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAGridButton( + val elementId: String, + val titleVariants: List, + val image: String?, + val imageData: ByteArray?, + val loadingMessage: String?, + val isOnPressListenerActive: Boolean, +) { + val title: String get() = titleVariants.firstOrNull() ?: "" + + companion object { + fun fromJson(map: Map): FAAGridButton { + val elementId = map["_elementId"] as? String ?: "" + val titleVariants = (map["titleVariants"] as? List<*>) + ?.filterIsInstance() ?: emptyList() + val image = map["image"] as? String + val imageData = map["imageData"] as? ByteArray + val loadingMessage = map["loadingMessage"] as? String + val isOnPressListenerActive = map["onPress"] as? Boolean ?: false + + return FAAGridButton( + elementId, + titleVariants, + image, + imageData, + loadingMessage, + isOnPressListenerActive, + ) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridTemplate.kt new file mode 100644 index 0000000..6f6f392 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridTemplate.kt @@ -0,0 +1,24 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAGridTemplate( + val elementId: String, + val title: String, + val buttons: List, + val emptyViewTitleVariants: List, +) { + companion object { + fun fromJson(map: Map): FAAGridTemplate { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val buttons = (map["buttons"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>) + ?.mapKeys { entry -> entry.key.toString() } + ?.let { btn -> FAAGridButton.fromJson(btn) } + } ?: emptyList() + val emptyViewTitleVariants = (map["emptyViewTitleVariants"] as? List<*>) + ?.filterIsInstance() ?: emptyList() + + return FAAGridTemplate(elementId, title, buttons, emptyViewTitleVariants) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListItem.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListItem.kt new file mode 100644 index 0000000..4ac2fd1 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListItem.kt @@ -0,0 +1,73 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAListItem( + val elementId: String, + val title: String, + val subtitle: String? = null, + val imageUrl: String? = null, + val imageData: ByteArray? = null, + val imageTint: FAAImageTint? = null, + val trailingImage: String? = null, + val trailingImageData: ByteArray? = null, + val trailingImageTint: FAAImageTint? = null, + val loadingMessage: String? = null, + val isBrowsable: Boolean? = null, + val toggle: FAAToggle? = null, + val isOnPressListenerActive: Boolean, +) { + companion object { + fun fromJson(map: Map): FAAListItem { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val subtitle = map["subtitle"] as? String + val imageUrl = map["imageUrl"] as? String ?: map["image"] as? String + val imageData = map["imageData"] as? ByteArray + val imageTint = FAAImageTint.fromJson(map["imageTint"] as? Map) + val trailingImage = map["trailingImage"] as? String + val trailingImageData = map["trailingImageData"] as? ByteArray + val trailingImageTint = + FAAImageTint.fromJson(map["trailingImageTint"] as? Map) + val loadingMessage = map["loadingMessage"] as? String + val isBrowsable = map["isBrowsable"] as? Boolean + val toggle = (map["toggle"] as? Map<*, *>)?.mapKeys { entry -> + entry.key.toString() + }?.let { FAAToggle.fromJson(it) } + val isOnPressListenerActive = map["onPress"] as? Boolean ?: false + + return FAAListItem( + elementId, + title, + subtitle, + imageUrl, + imageData, + imageTint, + trailingImage, + trailingImageData, + trailingImageTint, + loadingMessage, + isBrowsable, + toggle, + isOnPressListenerActive, + ) + } + } +} + +data class FAAToggle( + val isChecked: Boolean = false, + val isEnabled: Boolean? = null, + val isOnCheckedChangeListenerActive: Boolean = false, +) { + companion object { + fun fromJson(map: Map): FAAToggle { + val isChecked = map["isChecked"] as? Boolean ?: false + val isEnabled = map["isEnabled"] as? Boolean + val isOnCheckedChangeListenerActive = + map["onCheckedChange"] as? Boolean ?: false + + return FAAToggle( + isChecked, isEnabled, isOnCheckedChangeListenerActive + ) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListSection.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListSection.kt new file mode 100644 index 0000000..ff3bedc --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListSection.kt @@ -0,0 +1,30 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAListSection( + val elementId: String, + val title: String, + val items: List, + val selectedIndex: Int? = null, + val isOnSelectedListenerActive: Boolean = false, +) { + val isSelectable: Boolean + get() = selectedIndex != null || isOnSelectedListenerActive + + companion object { + fun fromJson(map: Map): FAAListSection { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val items = (map["items"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAAListItem.fromJson(it) } + } ?: emptyList() + val selectedIndex = map["selectedIndex"] as? Int + val isOnSelectedListenerActive = + map["onSelected"] as? Boolean ?: false + + return FAAListSection( + elementId, title, items, selectedIndex, isOnSelectedListenerActive + ) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListTemplate.kt new file mode 100644 index 0000000..8b7b3dd --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListTemplate.kt @@ -0,0 +1,31 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAListTemplate( + val elementId: String, + val title: String, + val sections: List, + val emptyViewTitleVariants: List, +) { + init { + val hasSelectableList = sections.any { it.isSelectable } + val isSingleUntitledList = sections.size == 1 && sections.first().title.isEmpty() + require(!hasSelectableList || isSingleUntitledList) { + "A selectable AAListSection must be the only section in an AAListTemplate and must not have a title." + } + } + + companion object { + fun fromJson(map: Map): FAAListTemplate { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val sections = (map["sections"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAAListSection.fromJson(it) } + } ?: emptyList() + val emptyViewTitleVariants = (map["emptyViewTitleVariants"] as? List<*>) + ?.filterIsInstance() ?: emptyList() + + return FAAListTemplate(elementId, title, sections, emptyViewTitleVariants) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/message/FAAMessageTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/message/FAAMessageTemplate.kt new file mode 100644 index 0000000..00aec88 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/message/FAAMessageTemplate.kt @@ -0,0 +1,17 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAMessageTemplate( + val elementId: String, + val title: String, + val message: String, +) { + companion object { + fun fromJson(map: Map): FAAMessageTemplate { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val message = map["message"] as? String ?: "" + + return FAAMessageTemplate(elementId, title, message) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneAction.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneAction.kt new file mode 100644 index 0000000..5be2300 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneAction.kt @@ -0,0 +1,33 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAPaneAction( + val elementId: String, + val title: String, + val imageUrl: String? = null, + val imageData: ByteArray? = null, + val imageTint: FAAImageTint? = null, + val isPrimary: Boolean = false, + val isOnPressListenerActive: Boolean = false, +) { + companion object { + fun fromJson(map: Map): FAAPaneAction { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val imageUrl = map["imageUrl"] as? String + val imageData = map["imageData"] as? ByteArray + val imageTint = FAAImageTint.fromJson(map["imageTint"] as? Map) + val isPrimary = map["isPrimary"] as? Boolean ?: false + val isOnPressListenerActive = map["onPress"] as? Boolean ?: false + + return FAAPaneAction( + elementId, + title, + imageUrl, + imageData, + imageTint, + isPrimary, + isOnPressListenerActive, + ) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneItem.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneItem.kt new file mode 100644 index 0000000..03e6ada --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneItem.kt @@ -0,0 +1,23 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAPaneItem( + val elementId: String, + val title: String, + val detail: String? = null, + val imageUrl: String? = null, + val imageData: ByteArray? = null, + val imageTint: FAAImageTint? = null, +) { + companion object { + fun fromJson(map: Map): FAAPaneItem { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val detail = map["detail"] as? String + val imageUrl = map["imageUrl"] as? String + val imageData = map["imageData"] as? ByteArray + val imageTint = FAAImageTint.fromJson(map["imageTint"] as? Map) + + return FAAPaneItem(elementId, title, detail, imageUrl, imageData, imageTint) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneTemplate.kt new file mode 100644 index 0000000..395fac6 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneTemplate.kt @@ -0,0 +1,42 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAPaneTemplate( + val elementId: String, + val title: String, + val items: List, + val actions: List, + val imageUrl: String? = null, + val imageData: ByteArray? = null, + val imageTint: FAAImageTint? = null, + val isLoading: Boolean = false, +) { + companion object { + fun fromJson(map: Map): FAAPaneTemplate { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val items = (map["items"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAAPaneItem.fromJson(it) } + } ?: emptyList() + val actions = (map["actions"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAAPaneAction.fromJson(it) } + } ?: emptyList() + val imageUrl = map["imageUrl"] as? String + val imageData = map["imageData"] as? ByteArray + val imageTint = FAAImageTint.fromJson(map["imageTint"] as? Map) + val isLoading = map["isLoading"] as? Boolean ?: false + + return FAAPaneTemplate( + elementId, + title, + items, + actions, + imageUrl, + imageData, + imageTint, + isLoading, + ) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarItem.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarItem.kt new file mode 100644 index 0000000..a94e6a5 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarItem.kt @@ -0,0 +1,26 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAATabBarItem( + val elementId: String, + val runtimeType: String, + val templateData: Map, + val tabTitle: String, + val systemIcon: String?, + val iconUrl: String?, +) { + companion object { + fun fromJson(map: Map): FAATabBarItem { + val elementId = map["elementId"] as? String ?: "" + val runtimeType = map["runtimeType"] as? String ?: "" + val templateData = (map["template"] as? Map<*, *>) + ?.mapKeys { it.key.toString() } ?: emptyMap() + val tabTitle = (templateData["tabTitle"] as? String) + ?: (templateData["title"] as? String) + ?: "" + val systemIcon = templateData["systemIcon"] as? String + val iconUrl = templateData["iconUrl"] as? String + + return FAATabBarItem(elementId, runtimeType, templateData, tabTitle, systemIcon, iconUrl) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarTemplate.kt new file mode 100644 index 0000000..9c43702 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarTemplate.kt @@ -0,0 +1,22 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAATabBarTemplate( + val elementId: String, + val tabs: List, +) { + companion object { + fun fromJson(map: Map): FAATabBarTemplate { + val elementId = map["_elementId"] as? String ?: "" + val tabs = (map["tabs"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAATabBarItem.fromJson(it) } + } ?: emptyList() + tabs.forEach { tab -> + if (tab.runtimeType == "FAAListTemplate") { + FAAListTemplate.fromJson(tab.templateData) + } + } + return FAATabBarTemplate(elementId, tabs) + } + } +} diff --git a/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/FAATabBarTemplateTest.kt b/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/FAATabBarTemplateTest.kt new file mode 100644 index 0000000..a1c6456 --- /dev/null +++ b/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/FAATabBarTemplateTest.kt @@ -0,0 +1,60 @@ +package com.oguzhnatly.flutter_android_auto + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class FAATabBarTemplateTest { + @Test + fun `rejects an invalid selectable list in a non-active tab`() { + val exception = assertThrows(IllegalArgumentException::class.java) { + FAATabBarTemplate.fromJson( + mapOf( + "_elementId" to "tabs", + "tabs" to listOf( + listTab("first", listOf(listSection())), + listTab( + "invalid", + listOf( + listSection( + title = "Options", + selectedIndex = 0, + ), + ), + ), + ), + ), + ) + } + + assertEquals( + "A selectable AAListSection must be the only section in an AAListTemplate and must not have a title.", + exception.message, + ) + } + + private fun listTab(id: String, sections: List>) = mapOf( + "elementId" to id, + "runtimeType" to "FAAListTemplate", + "template" to mapOf( + "_elementId" to id, + "title" to id, + "sections" to sections, + ), + ) + + private fun listSection( + title: String = "", + selectedIndex: Int? = null, + ) = mapOf( + "_elementId" to "section-$title", + "title" to title, + "selectedIndex" to selectedIndex, + "items" to listOf( + mapOf( + "_elementId" to "item-$title", + "title" to "Item", + ), + ), + ) +} diff --git a/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResultTest.kt b/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResultTest.kt new file mode 100644 index 0000000..03a0912 --- /dev/null +++ b/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResultTest.kt @@ -0,0 +1,79 @@ +package com.oguzhnatly.flutter_android_auto + +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MethodCallResultTest { + @Test + fun `successful call completes the result exactly once`() = runBlocking { + val result = RecordingResult() + + CoroutineScope(coroutineContext).launchMethodCall(result) { true }.join() + + assertEquals(listOf(true), result.successes) + assertTrue(result.errors.isEmpty()) + assertEquals(1, result.completionCount) + } + + @Test + fun `failed call reports the exception exactly once`() = runBlocking { + val result = RecordingResult() + + CoroutineScope(coroutineContext).launchMethodCall(result) { + throw IllegalStateException("Template creation failed") + }.join() + + assertTrue(result.successes.isEmpty()) + assertEquals(1, result.errors.size) + assertEquals("android_auto_error", result.errors.single().code) + assertEquals("Template creation failed", result.errors.single().message) + assertEquals(1, result.completionCount) + } + + @Test + fun `cancelled call reports cancellation exactly once`() = runBlocking { + val result = RecordingResult() + + CoroutineScope(coroutineContext).launchMethodCall(result) { + throw CancellationException("Plugin detached") + }.join() + + assertTrue(result.successes.isEmpty()) + assertEquals(1, result.errors.size) + assertEquals("operation_cancelled", result.errors.single().code) + assertEquals("Plugin detached", result.errors.single().message) + assertEquals(1, result.completionCount) + } +} + +private class RecordingResult : MethodChannel.Result { + data class Error( + val code: String, + val message: String?, + val details: Any?, + ) + + val successes = mutableListOf() + val errors = mutableListOf() + var notImplementedCount = 0 + + val completionCount: Int + get() = successes.size + errors.size + notImplementedCount + + override fun success(result: Any?) { + successes.add(result) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + errors.add(Error(errorCode, errorMessage, errorDetails)) + } + + override fun notImplemented() { + notImplementedCount++ + } +} diff --git a/third_party/flutter_carplay/lib/aa_models/alert/alert_action.dart b/third_party/flutter_carplay/lib/aa_models/alert/alert_action.dart new file mode 100644 index 0000000..b050ac9 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/alert/alert_action.dart @@ -0,0 +1,37 @@ +import 'package:uuid/uuid.dart'; + +/// Display styles for an alert action button on Android Auto. +/// +/// - [normal] — default appearance, no background color applied. +/// - [cancel] — no native equivalent in the Car App Library; renders the same as [normal]. +/// - [destructive] — renders with a red background (`CarColor.RED`). +enum AAAlertActionStyle { normal, cancel, destructive } + +/// An action that can be performed from an [AAAlertTemplate] on Android Auto. +class AAAlertAction { + final String _elementId; + + /// The button label. + final String title; + + /// Visual style hint for the button. + final AAAlertActionStyle style; + + /// Called when the user taps this action. + final Function() onPress; + + AAAlertAction({ + required this.title, + this.style = AAAlertActionStyle.normal, + required this.onPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'style': style.name, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/alert/alert_template.dart b/third_party/flutter_carplay/lib/aa_models/alert/alert_template.dart new file mode 100644 index 0000000..40d9daf --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/alert/alert_template.dart @@ -0,0 +1,49 @@ +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'alert_action.dart'; + +/// A template that presents a modal alert on Android Auto. +/// +/// Rendered as a full-screen [MessageTemplate] from the Car App Library. +/// Unlike CarPlay, Android Auto does not support true overlay modals, so the +/// alert occupies the entire screen and is pushed onto the navigation stack. +/// +/// Use [FlutterAndroidAuto.showAlert] to present it and +/// [FlutterAndroidAuto.popModal] to dismiss it programmatically. +class AAAlertTemplate implements AATemplate { + final String _elementId; + + /// Primary title shown at the top of the alert. + final String title; + + /// Optional body message displayed below the title. + final String? message; + + /// The action buttons available on the alert. + final List actions; + + /// Called when the alert finishes presenting. + /// [completed] is true when the alert was shown successfully. + final Function(bool completed)? onPresent; + + AAAlertTemplate({ + required this.title, + this.message, + required this.actions, + this.onPresent, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + String get uniqueId => _elementId; + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'message': message, + 'actions': actions.map((e) => e.toJson()).toList(), + 'onPresent': onPresent != null, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/alert/all.dart b/third_party/flutter_carplay/lib/aa_models/alert/all.dart new file mode 100644 index 0000000..b09017d --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/alert/all.dart @@ -0,0 +1,2 @@ +export 'alert_action.dart'; +export 'alert_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/all.dart b/third_party/flutter_carplay/lib/aa_models/all.dart new file mode 100644 index 0000000..b30e8d4 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/all.dart @@ -0,0 +1,8 @@ +export 'alert/all.dart'; +export 'grid/all.dart'; +export 'list/all.dart'; +export 'map/all.dart'; +export 'message/all.dart'; +export 'pane/all.dart'; +export 'tabbar/all.dart'; +export 'template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/grid/all.dart b/third_party/flutter_carplay/lib/aa_models/grid/all.dart new file mode 100644 index 0000000..2a5672a --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/grid/all.dart @@ -0,0 +1,2 @@ +export 'grid_button.dart'; +export 'grid_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/grid/grid_button.dart b/third_party/flutter_carplay/lib/aa_models/grid/grid_button.dart new file mode 100644 index 0000000..b47a374 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/grid/grid_button.dart @@ -0,0 +1,52 @@ +import 'package:uuid/uuid.dart'; + +/// A single cell in an [AAGridTemplate]. +/// +/// Each button shows a [titleVariants] label and an optional [image]. +/// The first element of [titleVariants] is used as the primary title on +/// Android Auto (Car App Library shows the most appropriate variant +/// according to available space). +class AAGridButton { + /// Unique id of the object. + final String _elementId; + + /// Label variants displayed beneath the cell image. Must not be empty. + final List titleVariants; + + /// Image displayed inside the grid cell. Supports three formats: + /// - **Asset** (pubspec.yaml): `'images/logo.png'` + /// - **Local file**: `'file:///path/to/image.png'` + /// - **Network URL**: `'https://example.com/image.png'` + /// + /// Falls back to a default icon when null or when the load fails. + final String? image; + + /// Text displayed as the loading screen title while [onPress] is executing + /// (until [complete] is called). When null, no title is shown. + final String? loadingMessage; + + /// Callback fired when the user taps this button. + /// + /// - `complete` must be called after processing to dismiss the loading screen + /// and rebuild the template — identical to [AAListItem] behaviour. + /// - `self` is a reference to the tapped button itself. + final Future Function(Function() complete, AAGridButton self)? onPress; + + AAGridButton({ + required this.titleVariants, + this.image, + this.loadingMessage, + this.onPress, + }) : assert(titleVariants.isNotEmpty, 'titleVariants must not be empty'), + _elementId = const Uuid().v4(); + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'titleVariants': titleVariants, + 'image': image, + 'loadingMessage': loadingMessage, + 'onPress': onPress != null, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/grid/grid_template.dart b/third_party/flutter_carplay/lib/aa_models/grid/grid_template.dart new file mode 100644 index 0000000..b00abdb --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/grid/grid_template.dart @@ -0,0 +1,66 @@ +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'grid_button.dart'; + +/// A template that displays a grid of tappable cells on Android Auto. +/// +/// Rendered as [GridTemplate] from the Car App Library. Android Auto +/// recommends a maximum of 8 buttons per grid. +/// +/// When used as a tab inside an [AATabBarTemplate], the tab label is taken +/// from [tabTitle] (falling back to [title]). An optional tab icon can be +/// provided via [systemIcon] or [iconUrl]. +class AAGridTemplate implements AATemplate { + /// Unique id of the object. + final String _elementId; + + /// Title displayed in the header of the template. + final String title; + + /// The grid cells. Android Auto recommends a maximum of 8 items. + final List buttons; + + /// Messages displayed when [buttons] is empty. + /// + /// Android Auto uses the first element as the text passed to + /// `ItemList.Builder.setNoItemsMessage()`. When [buttons] is empty and + /// this list is null or empty, the template shows a loading indicator. + final List? emptyViewTitleVariants; + + /// Label displayed on the tab bar item when this template is used as a tab + /// inside an [AATabBarTemplate]. Falls back to [title] when not set. + final String? tabTitle; + + /// SF Symbol / icon name used to resolve a [CarIcon] for the tab bar item. + /// Common names such as "map", "house", "star" are mapped to Car App + /// Library built-in icons. Unknown names fall back to a default. + final String? systemIcon; + + /// URL of an image to use as the tab bar icon. Loaded asynchronously. + /// Takes precedence over [systemIcon] when both are set. + final String? iconUrl; + + AAGridTemplate({ + required this.title, + required this.buttons, + this.emptyViewTitleVariants, + this.tabTitle, + this.systemIcon, + this.iconUrl, + }) : _elementId = const Uuid().v4(); + + @override + String get uniqueId => _elementId; + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'buttons': buttons.map((b) => b.toJson()).toList(), + 'emptyViewTitleVariants': emptyViewTitleVariants, + 'tabTitle': tabTitle, + 'systemIcon': systemIcon, + 'iconUrl': iconUrl, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/list/all.dart b/third_party/flutter_carplay/lib/aa_models/list/all.dart new file mode 100644 index 0000000..1e9bac5 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/list/all.dart @@ -0,0 +1,3 @@ +export 'list_item.dart'; +export 'list_section.dart'; +export 'list_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/list/list_item.dart b/third_party/flutter_carplay/lib/aa_models/list/list_item.dart new file mode 100644 index 0000000..1647b98 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/list/list_item.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:flutter_carplay/models/common/image_tint.dart'; +import 'package:uuid/uuid.dart'; + +class AAToggle { + bool isChecked; + final bool? isEnabled; + final Function(bool checked, AAListItem self)? onCheckedChange; + + AAToggle({this.isChecked = false, this.isEnabled, this.onCheckedChange}); + + Map toJson() => { + 'isChecked': isChecked, + 'isEnabled': isEnabled, + 'onCheckedChange': onCheckedChange != null ? true : false, + }; +} + +class AAListItem { + /// Unique id of the object. + final String _elementId; + + final String title; + final String? subtitle; + + /// The image displayed for this row on Android Auto. + /// + /// Supports asset paths, SVG assets, file paths, and network URLs. + final String? imageUrl; + final AutoImageTint? imageTint; + + /// The image displayed on the trailing side of this row when supported by the + /// Android Auto host. + final String? trailingImage; + + /// Optional tint applied to [trailingImage]. + final AutoImageTint? trailingImageTint; + + /// Text displayed as the loading screen title while [onPress] is executing + /// until [complete] is called. When null, no title is shown. + final String? loadingMessage; + + final bool? isBrowsable; + final AAToggle? toggle; + final FutureOr Function(Function() complete, AAListItem self)? onPress; + + AAListItem({ + required this.title, + this.subtitle, + String? image, + String? imageUrl, + this.imageTint, + this.trailingImage, + this.trailingImageTint, + this.loadingMessage, + this.isBrowsable, + this.toggle, + this.onPress, + String? id, + }) : imageUrl = imageUrl ?? image, + assert( + isBrowsable != true || toggle == null, + 'A browsable row must not have a toggle set.', + ), + assert( + isBrowsable != true || onPress != null, + 'A browsable row must have an onClickListener set.', + ), + assert( + toggle == null || onPress == null, + 'If a row contains a toggle, it must not have an onClickListener set.', + ), + _elementId = id ?? const Uuid().v4(); + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'subtitle': subtitle, + 'imageUrl': imageUrl, + 'imageTint': imageTint?.toJson(), + 'trailingImage': trailingImage, + 'trailingImageTint': trailingImageTint?.toJson(), + 'loadingMessage': loadingMessage, + 'isBrowsable': isBrowsable, + 'toggle': toggle?.toJson(), + 'onPress': onPress != null ? true : false, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/list/list_section.dart b/third_party/flutter_carplay/lib/aa_models/list/list_section.dart new file mode 100644 index 0000000..d622566 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/list/list_section.dart @@ -0,0 +1,54 @@ +import 'package:uuid/uuid.dart'; + +import 'list_item.dart'; + +class AAListSection { + /// Unique id of the object. + final String _elementId; + + /// Required only when multiple [AAListSection] are used in the same [AAListTemplate]. + final String? title; + + final List items; + int? selectedIndex; + final Function(int selectedIndex, AAListItem selectedItem)? onSelected; + + AAListSection({ + this.title, + required this.items, + this.selectedIndex, + this.onSelected, + String? id, + }) : assert( + selectedIndex == null || + (selectedIndex >= 0 && selectedIndex < items.length), + 'selectedIndex must be within the list item range.', + ), + assert( + (selectedIndex == null && onSelected == null) || items.isNotEmpty, + 'A selectable list must have at least one item.', + ), + assert( + selectedIndex == null && onSelected == null || + items.every((AAListItem item) => item.onPress == null), + 'Selectable list items must not have an onClickListener set.', + ), + assert( + selectedIndex == null && onSelected == null || + items.every((AAListItem item) => item.toggle == null), + 'Selectable list items must not have a toggle set.', + ), + _elementId = id ?? const Uuid().v4(); + + String get uniqueId => _elementId; + + bool get isSelectable => selectedIndex != null || onSelected != null; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'items': items.map((AAListItem item) => item.toJson()).toList(), + 'selectedIndex': selectedIndex, + 'onSelected': onSelected != null ? true : false, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/list/list_template.dart b/third_party/flutter_carplay/lib/aa_models/list/list_template.dart new file mode 100644 index 0000000..9a0c06a --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/list/list_template.dart @@ -0,0 +1,91 @@ +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'list_section.dart'; + +class AAListTemplate implements AATemplate { + /// Unique id of the object. + final String _elementId; + + final String title; + final List sections; + + /// An array of title variants displayed when the list is empty. + /// + /// Android Auto uses the first element as the message passed to + /// `ItemList.Builder.setNoItemsMessage()`. When [sections] is empty and + /// this list is null, the template falls back to a loading indicator. + final List? emptyViewTitleVariants; + + /// Label displayed on the tab bar item when this template is used as a tab + /// inside an [AATabBarTemplate]. Falls back to [title] when not set. + final String? tabTitle; + + /// Icon displayed in the tab when this template is used inside an + /// [AATabBarTemplate]. Supports mapped system icon names, Flutter assets, + /// local files, and network URLs. + /// + /// When [iconUrl] is also set, [iconUrl] takes precedence. + final String? systemIcon; + + /// URL of an image to use as the tab bar icon. Loaded asynchronously. + /// Takes precedence over [systemIcon] when set. + final String? iconUrl; + + AAListTemplate({ + required this.title, + required this.sections, + this.emptyViewTitleVariants, + this.tabTitle, + this.systemIcon, + this.iconUrl, + String? id, + }) : assert( + _hasValidSelectableList(sections), + 'A selectable AAListSection must be the only section in an ' + 'AAListTemplate and must not have a title.', + ), + _elementId = id ?? const Uuid().v4(); + + @override + String get uniqueId => _elementId; + + static bool _hasValidSelectableList(List sections) { + final selectableSections = sections.where( + (AAListSection section) => section.isSelectable, + ); + if (selectableSections.isEmpty) return true; + + return sections.length == 1 && + (selectableSections.single.title == null || + selectableSections.single.title!.isEmpty); + } + + static void _validateSelectableList(List sections) { + assert( + _hasValidSelectableList(sections), + 'A selectable AAListSection must be the only section in an ' + 'AAListTemplate and must not have a title.', + ); + } + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'sections': + sections.map((AAListSection section) => section.toJson()).toList(), + 'emptyViewTitleVariants': emptyViewTitleVariants, + 'tabTitle': tabTitle, + 'systemIcon': systemIcon, + 'iconUrl': iconUrl, + }; + + void updateSections(List newSections) { + _validateSelectableList(newSections); + final copy = List.from(newSections); + sections + ..clear() + ..addAll(copy); + } +} diff --git a/third_party/flutter_carplay/lib/aa_models/map/all.dart b/third_party/flutter_carplay/lib/aa_models/map/all.dart new file mode 100644 index 0000000..6f5d219 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/map/all.dart @@ -0,0 +1,2 @@ +export 'map_action.dart'; +export 'map_with_content_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/map/map_action.dart b/third_party/flutter_carplay/lib/aa_models/map/map_action.dart new file mode 100644 index 0000000..3531274 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/map/map_action.dart @@ -0,0 +1,42 @@ +import 'package:uuid/uuid.dart'; + +/// DELTA D from upstream 1.6.5: an action in a map action strip. +/// +/// The strip is the vertical bar the host draws down the right edge of the map +/// region. Its constraints are stricter than a pane's — `ACTIONS_CONSTRAINTS_MAP` +/// allows at most 4 actions and 1 primary, and leaves `maxCustomTitles` at 0, so +/// **an action without an icon is rejected**. [imageUrl] is therefore required, +/// not decorative; [title] is carried only for accessibility and for the +/// host to use if it ever renders one. +class AAMapAction { + AAMapAction({ + required this.title, + required this.imageUrl, + this.isPrimary = false, + this.onPress, + String? id, + }) : assert(title.isNotEmpty, 'AAMapAction.title cannot be empty'), + assert(imageUrl.isNotEmpty, + 'AAMapAction.imageUrl is required — the map strip is icon-only'), + _elementId = id ?? const Uuid().v4(); + + final String _elementId; + final String title; + + /// Flutter asset path, file:// path or network URL — resolved to a CarIcon + /// natively by the same loader pane and grid images use. + final String imageUrl; + + final bool isPrimary; + final Function()? onPress; + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'imageUrl': imageUrl, + 'isPrimary': isPrimary, + 'onPress': onPress != null, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/map/map_with_content_template.dart b/third_party/flutter_carplay/lib/aa_models/map/map_with_content_template.dart new file mode 100644 index 0000000..db338e7 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/map/map_with_content_template.dart @@ -0,0 +1,56 @@ +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'map_action.dart'; + +/// DELTA D from upstream 1.6.5: Android Auto's MapWithContentTemplate. +/// +/// The host renders no map here. It reserves a map region and hands the app a +/// Surface through `AppManager.setSurfaceCallback`; whatever the app draws onto +/// that Surface is the map. See `FAASurfaceProvider` on the native side. +/// +/// [contentTemplate] is any supported template — it is dispatched through the +/// same builder as a root template — and occupies the content half beside the +/// map. +/// +/// Requires car API level 7, the `androidx.car.app.MAP_TEMPLATES` and +/// `androidx.car.app.ACCESS_SURFACE` permissions, and an app category permitted +/// to draw maps (navigation, POI, or weather — **not** IOT). +class AAMapWithContentTemplate implements AATemplate { + AAMapWithContentTemplate({ + required this.contentTemplate, + this.mapActions = const [], + String? id, + }) : assert(mapActions.length <= 4, + 'A map action strip cannot hold more than 4 actions'), + assert( + mapActions.where((AAMapAction a) => a.isPrimary).length <= 1, + 'A map action strip cannot hold more than 1 primary action', + ), + _elementId = id ?? const Uuid().v4(); + + final String _elementId; + final AATemplate contentTemplate; + + /// The vertical strip down the right edge of the map. Empty means no strip. + final List mapActions; + + @override + String get uniqueId => _elementId; + + /// The native side needs the content's runtime type to dispatch it, exactly + /// as the root template does. + static String runtimeTypeOf(AATemplate template) { + final name = template.runtimeType.toString(); + return name.startsWith('AA') ? 'FAA${name.substring(2)}' : 'FAA$name'; + } + + @override + Map toJson() => { + '_elementId': _elementId, + 'contentRuntimeType': runtimeTypeOf(contentTemplate), + 'contentTemplate': contentTemplate.toJson(), + 'mapActions': + mapActions.map((AAMapAction action) => action.toJson()).toList(), + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/message/all.dart b/third_party/flutter_carplay/lib/aa_models/message/all.dart new file mode 100644 index 0000000..96ab4f1 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/message/all.dart @@ -0,0 +1,2 @@ +export 'long_message_template.dart'; +export 'message_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/message/long_message_template.dart b/third_party/flutter_carplay/lib/aa_models/message/long_message_template.dart new file mode 100644 index 0000000..a68c326 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/message/long_message_template.dart @@ -0,0 +1,18 @@ +import '../../constants/private_constants.dart'; +import 'message_template_base.dart'; + +class AALongMessageTemplate extends AAMessageTemplateBase { + /// Creates a long message template for Android Auto. + /// + /// [message] must not be empty because Android Auto requires a non-empty + /// message when building the native template. + AALongMessageTemplate({ + required super.title, + required super.message, + super.id, + }); + + @override + FAAChannelTypes get updateChannelType => + FAAChannelTypes.updateLongMessageTemplate; +} diff --git a/third_party/flutter_carplay/lib/aa_models/message/message_template.dart b/third_party/flutter_carplay/lib/aa_models/message/message_template.dart new file mode 100644 index 0000000..470a78d --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/message/message_template.dart @@ -0,0 +1,14 @@ +import '../../constants/private_constants.dart'; +import 'message_template_base.dart'; + +class AAMessageTemplate extends AAMessageTemplateBase { + /// Creates a message template for Android Auto. + /// + /// [message] must not be empty because Android Auto requires a non-empty + /// message when building the native template. + AAMessageTemplate({required super.title, required super.message, super.id}); + + @override + FAAChannelTypes get updateChannelType => + FAAChannelTypes.updateMessageTemplate; +} diff --git a/third_party/flutter_carplay/lib/aa_models/message/message_template_base.dart b/third_party/flutter_carplay/lib/aa_models/message/message_template_base.dart new file mode 100644 index 0000000..cc807e7 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/message/message_template_base.dart @@ -0,0 +1,76 @@ +import 'package:flutter_carplay/controllers/android_auto_controller.dart'; +import 'package:uuid/uuid.dart'; + +import '../../constants/private_constants.dart'; +import '../template.dart'; + +abstract class AAMessageTemplateBase implements AATemplate { + /// Unique id of the object. + final String _elementId; + + String title; + String message; + + AAMessageTemplateBase({ + required this.title, + required this.message, + String? id, + }) : _elementId = id ?? const Uuid().v4() { + _validateMessage(message); + } + + FAAChannelTypes get updateChannelType; + + @override + String get uniqueId => _elementId; + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'message': message, + }; + + /// Updates the template content on Android Auto. + /// + /// Android Auto templates are immutable. This method asks the native side to + /// rebuild the message template and invalidate the current screen. Changing + /// the title or message is treated by Android Auto as a new template step, + /// not as a refresh of the existing template, and can count toward host + /// template limits. + /// + /// [message] must not be empty. Native update errors are surfaced as + /// [PlatformException]s from the MethodChannel call. + Future update({String? title, String? message}) async { + final nextTitle = title ?? this.title; + final nextMessage = message ?? this.message; + _validateMessage(nextMessage); + + await FlutterAndroidAutoController.flutterToNativeModuleStatic( + updateChannelType, + {'elementId': _elementId, 'title': nextTitle, 'message': nextMessage}, + ); + + updateTemplate(title: nextTitle, message: nextMessage); + } + + Future setTitle(String title) { + return update(title: title); + } + + Future setMessage(String message) { + return update(message: message); + } + + void updateTemplate({required String title, required String message}) { + _validateMessage(message); + this.title = title; + this.message = message; + } + + static void _validateMessage(String message) { + if (message.isEmpty) { + throw ArgumentError.value(message, 'message', 'Message cannot be empty'); + } + } +} diff --git a/third_party/flutter_carplay/lib/aa_models/pane/all.dart b/third_party/flutter_carplay/lib/aa_models/pane/all.dart new file mode 100644 index 0000000..862d910 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/pane/all.dart @@ -0,0 +1,3 @@ +export 'pane_action.dart'; +export 'pane_item.dart'; +export 'pane_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/pane/pane_action.dart b/third_party/flutter_carplay/lib/aa_models/pane/pane_action.dart new file mode 100644 index 0000000..103a4b8 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/pane/pane_action.dart @@ -0,0 +1,39 @@ +import 'package:flutter_carplay/models/common/image_tint.dart'; +import 'package:uuid/uuid.dart'; + +class AAPaneAction { + /// Unique id of the object. + final String _elementId; + + final String title; + + /// Optional icon displayed with this pane action on Android Auto. + /// + /// Supports the same formats as [AAPaneItem.imageUrl], including Flutter + /// asset SVGs rasterized before the payload is sent to native Android. + final String? imageUrl; + final AutoImageTint? imageTint; + final bool isPrimary; + final Function()? onPress; + + AAPaneAction({ + required this.title, + this.imageUrl, + this.imageTint, + this.isPrimary = false, + this.onPress, + String? id, + }) : assert(title.isNotEmpty, 'AAPaneAction.title cannot be empty'), + _elementId = id ?? const Uuid().v4(); + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'imageUrl': imageUrl, + 'imageTint': imageTint?.toJson(), + 'isPrimary': isPrimary, + 'onPress': onPress != null, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/pane/pane_item.dart b/third_party/flutter_carplay/lib/aa_models/pane/pane_item.dart new file mode 100644 index 0000000..3a9d9b2 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/pane/pane_item.dart @@ -0,0 +1,40 @@ +import 'package:flutter_carplay/models/common/image_tint.dart'; +import 'package:uuid/uuid.dart'; + +class AAPaneItem { + /// Unique id of the object. + final String _elementId; + + final String title; + final String? detail; + + /// The image displayed for this informational row on Android Auto. + /// + /// Supports these formats: + /// - **Asset path**: `images/flutter_logo.png` (from pubspec.yaml assets) + /// - **SVG asset**: `images/icon.svg` (rasterized to PNG before being sent to + /// the native side; remote/`file://` SVGs are not supported) + /// - **File path**: `file:///path/to/image.png` (local file on device) + /// - **Network URL**: `https://example.com/image.png` (remote image) + final String? imageUrl; + final AutoImageTint? imageTint; + + AAPaneItem({ + required this.title, + this.detail, + this.imageUrl, + this.imageTint, + String? id, + }) : assert(title.isNotEmpty, 'AAPaneItem.title cannot be empty'), + _elementId = id ?? const Uuid().v4(); + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'detail': detail, + 'imageUrl': imageUrl, + 'imageTint': imageTint?.toJson(), + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/pane/pane_template.dart b/third_party/flutter_carplay/lib/aa_models/pane/pane_template.dart new file mode 100644 index 0000000..6825732 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/pane/pane_template.dart @@ -0,0 +1,80 @@ +import 'package:flutter_carplay/models/common/image_tint.dart'; +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'pane_action.dart'; +import 'pane_item.dart'; + +class AAPaneTemplate implements AATemplate { + /// Unique id of the object. + final String _elementId; + + /// DELTA D from upstream 1.6.5: optional, and empty by default. + /// + /// Upstream requires a non-empty title, but `PaneTemplate.Builder.build()` + /// does not — it validates only the pane's rows and actions. The title is what + /// draws the header, so an empty one means no header, which matters when the + /// pane is the content half of a MapWithContentTemplate and every row of + /// chrome is taken from the map. + final String title; + final List items; + final List actions; + + /// Optional image displayed alongside the pane content. + /// + /// Supports the same formats as [AAPaneItem.imageUrl], including Flutter + /// asset SVGs rasterized before the payload is sent to native Android. + final String? imageUrl; + final AutoImageTint? imageTint; + + /// Shows Android Auto's loading state instead of rows. + /// + /// Android requires pane content to be either loading with no rows, or + /// non-loading with at least one row. + /// + /// Android treats a loading pane followed by loaded content as a template + /// refresh. Switching an already-loaded pane back to loading changes the row + /// count, so it may count as a new template in Android Auto's template quota. + final bool isLoading; + + AAPaneTemplate({ + this.title = '', + required this.items, + this.actions = const [], + this.imageUrl, + this.imageTint, + this.isLoading = false, + String? id, + }) : assert( + isLoading || items.isNotEmpty, + 'AAPaneTemplate.items cannot be empty unless isLoading is true', + ), + assert( + !isLoading || items.isEmpty, + 'AAPaneTemplate.items must be empty when isLoading is true', + ), + assert( + actions.length <= 2, + 'AAPaneTemplate.actions cannot contain more than 2 actions', + ), + assert( + actions.where((AAPaneAction action) => action.isPrimary).length <= 1, + 'AAPaneTemplate.actions cannot contain more than 1 primary action', + ), + _elementId = id ?? const Uuid().v4(); + + @override + String get uniqueId => _elementId; + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'items': items.map((AAPaneItem item) => item.toJson()).toList(), + 'actions': + actions.map((AAPaneAction action) => action.toJson()).toList(), + 'imageUrl': imageUrl, + 'imageTint': imageTint?.toJson(), + 'isLoading': isLoading, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/tabbar/all.dart b/third_party/flutter_carplay/lib/aa_models/tabbar/all.dart new file mode 100644 index 0000000..b9ce2e6 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/tabbar/all.dart @@ -0,0 +1 @@ +export 'tabbar_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/tabbar/tabbar_template.dart b/third_party/flutter_carplay/lib/aa_models/tabbar/tabbar_template.dart new file mode 100644 index 0000000..5a24839 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/tabbar/tabbar_template.dart @@ -0,0 +1,59 @@ +import 'package:uuid/uuid.dart'; + +import '../grid/grid_template.dart'; +import '../list/list_template.dart'; +import '../template.dart'; + +/// A container template that displays multiple child templates as tabs on +/// Android Auto. Rendered as [TabTemplate] from the Car App Library (API 6+). +/// +/// Each child [AATemplate] is shown in its own tab. The tab's label is taken +/// from [AAListTemplate.tabTitle] (falling back to [AAListTemplate.title]). +/// An optional tab icon can be provided via [AAListTemplate.systemIcon] +/// (mapped to a CarIcon built-in) or [AAListTemplate.iconUrl] (loaded async). +/// +/// Currently supported child template types: [AAListTemplate]. +/// +/// Devices that do not support API level 6 will fall back to showing the first +/// tab's content as a plain [ListTemplate]. +class AATabBarTemplate implements AATemplate { + final String _elementId; + + /// The templates shown in each tab (max 5, per Android Auto restrictions). + final List tabs; + + AATabBarTemplate({ + required List tabs, + String? id, + }) : tabs = List.from(tabs), + _elementId = id ?? const Uuid().v4(); + + @override + String get uniqueId => _elementId; + + @override + Map toJson() => { + '_elementId': _elementId, + 'tabs': tabs + .map((t) => { + 'elementId': t.uniqueId, + 'runtimeType': _runtimeTypeOf(t), + 'template': t.toJson(), + }) + .toList(), + }; + + /// Updates the tabs list in-place (mirrors CPTabBarTemplate.updateTemplates). + void updateTabs(List newTabs) { + final copy = List.from(newTabs); + tabs + ..clear() + ..addAll(copy); + } + + static String _runtimeTypeOf(AATemplate t) { + if (t is AAListTemplate) return 'FAAListTemplate'; + if (t is AAGridTemplate) return 'FAAGridTemplate'; + return 'FAAUnknown'; + } +} diff --git a/third_party/flutter_carplay/lib/aa_models/template.dart b/third_party/flutter_carplay/lib/aa_models/template.dart new file mode 100644 index 0000000..51cd407 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/template.dart @@ -0,0 +1,7 @@ +abstract interface class AATemplate { + const AATemplate(); + + String get uniqueId; + + Map toJson(); +} diff --git a/third_party/flutter_carplay/lib/android_auto_worker.dart b/third_party/flutter_carplay/lib/android_auto_worker.dart new file mode 100644 index 0000000..12888ed --- /dev/null +++ b/third_party/flutter_carplay/lib/android_auto_worker.dart @@ -0,0 +1,268 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_carplay/flutter_carplay.dart'; + +import 'controllers/android_auto_controller.dart'; + +/// An object used to integrate Android Auto navigation and manage user +/// interface elements displayed on the Android Auto screen. +class FlutterAndroidAuto { + static final FlutterAndroidAutoController _androidAutoController = + FlutterAndroidAutoController(); + + late final StreamSubscription? _eventBroadcast; + + static String _connectionStatus = ConnectionStatusTypes.unknown.name; + + /// The size used when rasterizing Flutter asset SVGs referenced by image + /// fields before they are sent to the native side. + static int svgRasterSize = defaultSvgRasterSize; + + Function(ConnectionStatusTypes status)? _onAndroidAutoConnectionChange; + + FlutterAndroidAuto() { + if (defaultTargetPlatform != TargetPlatform.android) return; + + _eventBroadcast = _androidAutoController.eventChannel + .receiveBroadcastStream() + .listen((event) async { + final FAAChannelTypes receivedChannelType = EnumUtils.enumFromString( + FAAChannelTypes.values, + event['type'], + ); + + switch (receivedChannelType) { + case FAAChannelTypes.onAndroidAutoConnectionChange: + final ConnectionStatusTypes connectionStatus = + EnumUtils.enumFromString( + ConnectionStatusTypes.values, + event['data']['status'], + ); + _connectionStatus = connectionStatus.name; + _onAndroidAutoConnectionChange?.call(connectionStatus); + break; + + case FAAChannelTypes.onListItemSelected: + await _androidAutoController.processFAAListItemSelectedChannel( + event['data']['elementId'], + ); + break; + + case FAAChannelTypes.onListSectionSelected: + _androidAutoController.processFAAListSectionSelectedChannel( + event['data']['elementId'], + event['data']['selectedIndex'], + ); + break; + + case FAAChannelTypes.onToggleCheckedChange: + _androidAutoController.processFAAToggleCheckedChangeChannel( + event['data']['elementId'], + event['data']['checked'], + ); + break; + + case FAAChannelTypes.onPaneActionPressed: + _androidAutoController.processFAAPaneActionPressedChannel( + event['data']['elementId'], + ); + break; + + case FAAChannelTypes.onMapActionPressed: + _androidAutoController.processFAAMapActionPressedChannel( + event['data']['elementId'], + ); + break; + + case FAAChannelTypes.onScreenBackButtonPressed: + FlutterAndroidAutoController.templateHistory.removeWhere( + (AATemplate item) => item.uniqueId == event['data']['elementId'], + ); + break; + + case FAAChannelTypes.onAlertActionPressed: + _androidAutoController.processFAAAlertActionPressed( + event['data']['elementId'], + ); + break; + + case FAAChannelTypes.onPresentStateChanged: + final bool completed = event['data']['completed'] as bool? ?? false; + _androidAutoController.processFAAPresentStateChanged( + event['data']['elementId'], + completed, + ); + break; + + case FAAChannelTypes.onTabBarItemSelected: + break; + + case FAAChannelTypes.onGridButtonPressed: + await _androidAutoController.processFAAGridButtonPressed( + event['data']['elementId'], + ); + break; + + default: + break; + } + }); + } + + void closeConnection() { + _eventBroadcast!.cancel(); + } + + void resumeConnection() { + _eventBroadcast!.resume(); + } + + void pauseConnection() { + _eventBroadcast!.pause(); + } + + void addListenerOnConnectionChange( + Function(ConnectionStatusTypes status) onAndroidAutoConnectionChange, + ) { + _onAndroidAutoConnectionChange = onAndroidAutoConnectionChange; + } + + void removeListenerOnConnectionChange() { + _onAndroidAutoConnectionChange = null; + } + + static String get connectionStatus => _connectionStatus; + + static Future setRootTemplate({required AATemplate template}) async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.setRootTemplate, { + 'template': template.toJson(), + 'runtimeType': _getAARuntimeTypeString(template), + }); + + if (isCompleted == true) { + if (FlutterAndroidAutoController.templateHistory.isEmpty) { + FlutterAndroidAutoController.templateHistory.add(template); + } else { + FlutterAndroidAutoController.templateHistory[0] = template; + } + } + } + + Future updateListTemplateSections({ + required String elementId, + required List sections, + }) { + return FlutterAndroidAutoController.updateAAListTemplateSections( + elementId: elementId, + sections: sections, + ); + } + + static Future updatePaneTemplate({ + required AAPaneTemplate template, + }) async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.updatePaneTemplate, { + 'template': template.toJson(), + }); + + if (isCompleted == true) { + final int index = FlutterAndroidAutoController.templateHistory.indexWhere( + (AATemplate item) => item.uniqueId == template.uniqueId, + ); + if (index != -1) { + FlutterAndroidAutoController.templateHistory[index] = template; + } + } + + return isCompleted ?? false; + } + + Future forceUpdateRootTemplate() { + return _androidAutoController.flutterToNativeModule( + FAAChannelTypes.forceUpdateRootTemplate, + ); + } + + static dynamic get rootTemplate => + FlutterAndroidAutoController.currentRootTemplate; + + static Future showAlert({ + required AAAlertTemplate template, + }) async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.setAlert, { + 'template': template.toJson(), + }); + + if (isCompleted == true) { + FlutterAndroidAutoController.currentPresentTemplate = template; + } + template.onPresent?.call(isCompleted ?? false); + } + + static Future popModal() async { + FlutterAndroidAutoController.currentPresentTemplate = null; + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.closePresent); + return isCompleted ?? false; + } + + static Future updateTabBarTemplates({ + required AATabBarTemplate template, + }) async { + await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.updateTabBarTemplates, { + 'template': template.toJson(), + }); + + final index = FlutterAndroidAutoController.templateHistory + .indexWhere((item) => item.uniqueId == template.uniqueId); + if (index >= 0) { + FlutterAndroidAutoController.templateHistory[index] = template; + } + } + + static Future pop() async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.popTemplate); + return isCompleted ?? false; + } + + static Future popToRoot() async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.popToRootTemplate); + return isCompleted ?? false; + } + + static Future push({required AATemplate template}) async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.pushTemplate, { + 'template': template.toJson(), + 'runtimeType': _getAARuntimeTypeString(template), + }); + if (isCompleted == true) { + FlutterAndroidAutoController.templateHistory.add(template); + } + return isCompleted ?? false; + } + + static Future showSharedNowPlaying() async => false; + + static String _getAARuntimeTypeString(AATemplate template) { + if (template is AAListTemplate) return 'FAAListTemplate'; + if (template is AAGridTemplate) return 'FAAGridTemplate'; + if (template is AATabBarTemplate) return 'FAATabBarTemplate'; + if (template is AAPaneTemplate) return 'FAAPaneTemplate'; + // DELTA D + if (template is AAMapWithContentTemplate) { + return 'FAAMapWithContentTemplate'; + } + if (template is AAMessageTemplate) return 'FAAMessageTemplate'; + if (template is AALongMessageTemplate) return 'FAALongMessageTemplate'; + if (template is AAAlertTemplate) return 'FAAAlertTemplate'; + return 'FAA${template.runtimeType}'; + } +} diff --git a/third_party/flutter_carplay/lib/carplay_worker.dart b/third_party/flutter_carplay/lib/carplay_worker.dart new file mode 100644 index 0000000..7186d67 --- /dev/null +++ b/third_party/flutter_carplay/lib/carplay_worker.dart @@ -0,0 +1,455 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_carplay/controllers/carplay_controller.dart'; +import 'package:flutter_carplay/flutter_carplay.dart'; + +/// An object in order to integrate Apple CarPlay in navigation and +/// manage all user interface elements appearing on your screens displayed on +/// the CarPlay screen. +/// +/// Using CarPlay, you can display content from your app on a customized user interface +/// that is generated and hosted by the system itself. Control over UI elements, such as +/// touch target size, font size and color, highlights, and so on. +/// +/// **Useful Links:** +/// - [What is CarPlay?](https://developer.apple.com/carplay/) +/// - [Request CarPlay Framework](https://developer.apple.com/contact/carplay/) +/// - [Learn more about MFi Program](https://mfi.apple.com) +class FlutterCarplay { + /// A main Flutter CarPlay Controller to manage the system. + static final FlutterCarPlayController _carPlayController = + FlutterCarPlayController(); + + /// CarPlay main bridge as a listener from CarPlay and native side. + StreamSubscription? _eventBroadcast; + + /// Current CarPlay and mobile app connection status. + static String _connectionStatus = ConnectionStatusTypes.unknown.name; + + /// The size (in logical pixels, square) used when rasterizing Flutter asset + /// SVGs referenced by image fields (e.g. `CPListItem.image`, + /// `CPGridButton.image`, `CPPoi.image`) before they are sent to the native + /// side. Defaults to [defaultSvgRasterSize] (120). + static int svgRasterSize = defaultSvgRasterSize; + + /// A listener function, which will be triggered when CarPlay connection changes + /// and will be transmitted to the main code, allowing the user to access + /// the current connection status. + Function(ConnectionStatusTypes status)? _onCarplayConnectionChange; + + /// Creates an [FlutterCarplay] and starts the connection. + FlutterCarplay() { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + + _eventBroadcast = _carPlayController.eventChannel + .receiveBroadcastStream() + .listen((event) async { + final FCPChannelTypes receivedChannelType = + EnumUtils.enumFromString(FCPChannelTypes.values, event['type']); + switch (receivedChannelType) { + case FCPChannelTypes.onCarplayConnectionChange: + final ConnectionStatusTypes connectionStatus = + EnumUtils.enumFromString( + ConnectionStatusTypes.values, + event['data']['status'], + ); + _connectionStatus = connectionStatus.name; + if (_onCarplayConnectionChange != null) { + _onCarplayConnectionChange!(connectionStatus); + } + break; + case FCPChannelTypes.onFCPListItemSelected: + await _carPlayController.processFCPListItemSelectedChannel( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onFCPListImageRowItemSelected: + await _carPlayController.processFCPListImageRowItemSelectedChannel( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onFCPListImageRowItemElementSelected: + _carPlayController.processFCPListImageRowItemElementSelectedChannel( + event['data']['elementId'], + event['data']['index'], + ); + break; + case FCPChannelTypes.onFCPAlertActionPressed: + _carPlayController.processFCPAlertActionPressed( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onPresentStateChanged: + _carPlayController.processFCPAlertTemplateCompleted( + event['data']['completed'], + ); + break; + case FCPChannelTypes.onGridButtonPressed: + _carPlayController.processFCPGridButtonPressed( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onBarButtonPressed: + _carPlayController.processFCPBarButtonPressed( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onTextButtonPressed: + _carPlayController.processFCPTextButtonPressed( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onSearchTextUpdated: + _carPlayController.processFCPSearchTextUpdated( + event['data']['elementId'], + event['data']['searchText'], + ); + break; + case FCPChannelTypes.onSearchResultSelected: + _carPlayController.processFCPSearchResultSelected( + event['data']['elementId'], + event['data']['itemElementId'], + ); + break; + case FCPChannelTypes.onSearchButtonPressed: + _carPlayController.processFCPSearchButtonPressed( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onScreenBackButtonPressed: + final String elementId = event['data']['elementId']; + final CPTemplate? poppedTemplate = FlutterCarPlayController + .templateHistory + .where((item) => item.uniqueId == elementId) + .firstOrNull; + poppedTemplate?.onPop?.call(); + FlutterCarPlayController.templateHistory + .removeWhere((item) => item.uniqueId == elementId); + break; + default: + break; + } + }); + } + + /// A function that will disconnect all event listeners from CarPlay. The action + /// will be irrevocable, and a new [FlutterCarplay] controller must be created after this, + /// otherwise CarPlay will be unusable. + /// + /// [!] It is not recommended to use this function if you do not know what you are doing. + void closeConnection() { + _eventBroadcast?.cancel(); + } + + /// A function that will resume the paused all event listeners from CarPlay. + void resumeConnection() { + _eventBroadcast?.resume(); + } + + /// A function that will pause the all active event listeners from CarPlay. + void pauseConnection() { + _eventBroadcast?.pause(); + } + + /// Callback function will be fired when CarPlay connection status is changed. + /// For example, when CarPlay is connected to the device, in the background state, + /// or completely disconnected. + /// + /// See also: [ConnectionStatusTypes] + void addListenerOnConnectionChange( + Function(ConnectionStatusTypes status) onCarplayConnectionChange, + ) { + _onCarplayConnectionChange = onCarplayConnectionChange; + } + + /// Removes the callback function that has been set before in order to listen + /// on CarPlay connection status changed. + void removeListenerOnConnectionChange() { + _onCarplayConnectionChange = null; + } + + /// Current CarPlay connection status. It will return one of [ConnectionStatusTypes] as String. + static String get connectionStatus { + return _connectionStatus; + } + + /// Sets the root template of the navigation hierarchy. If a navigation + /// hierarchy already exists, CarPlay replaces the entire hierarchy. + /// + /// - rootTemplate is a template to use as the root of a new navigation hierarchy. If one exists, + /// it will replace the current rootTemplate. **Must be one of the type:** + /// [CPTabBarTemplate], [CPGridTemplate], [CPListTemplate], + /// [CPInformationTemplate], [CPPointOfInterestTemplate], [CPSearchTemplate] + /// If not, it will throw an [TypeError] + /// + /// - If animated is true, CarPlay animates the presentation of the template, but will be ignored + /// this flag when there isn’t an existing navigation hierarchy to replace. + /// + /// [!] CarPlay cannot have more than 5 templates on one screen. + static Future setRootTemplate({ + required CPTemplate rootTemplate, + bool animated = true, + }) async { + if (rootTemplate is CPTabBarTemplate || + rootTemplate is CPGridTemplate || + rootTemplate is CPListTemplate || + rootTemplate is CPInformationTemplate || + rootTemplate is CPPointOfInterestTemplate || + rootTemplate is CPSearchTemplate) { + return FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.setRootTemplate, { + 'rootTemplate': rootTemplate.toJson(), + 'animated': animated, + }).then((value) { + if (value == true) { + if (FlutterCarPlayController.templateHistory.isEmpty) { + FlutterCarPlayController.templateHistory.add(rootTemplate); + } else { + FlutterCarPlayController.templateHistory[0] = rootTemplate; + } + } + }); + } else { + throw TypeError(); + } + } + + /// It will set the current root template again. + Future forceUpdateRootTemplate() { + return FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.forceUpdateRootTemplate, + ); + } + + /// It will update the sections of the [CPListTemplate] which has the given [elementId]. + Future updateListTemplateSections({ + required String elementId, + required List sections, + }) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.updateListTemplateSections, + { + 'elementId': elementId, + 'sections': + sections.map((CPListSection section) => section.toJson()).toList(), + }, + ); + + if (isCompleted == true) { + final template = + FlutterCarPlayController.getTemplateFromHistory( + elementId); + template?.updateSections(sections); + } + return; + } + + /// It will update the information items of the [CPInformationTemplate] which has the given [elementId]. + Future updateInformationTemplateItems({ + required String elementId, + required List items, + }) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.updateInformationTemplateItems, + { + 'elementId': elementId, + 'items': items.map((CPInformationItem item) => item.toJson()).toList(), + }, + ); + + if (isCompleted == true) { + final template = FlutterCarPlayController.getTemplateFromHistory< + CPInformationTemplate>(elementId); + template?.updateInformationItems(items); + } + return; + } + + /// It will update the actions of the [CPInformationTemplate] which has the given [elementId]. + Future updateInformationTemplateActions({ + required String elementId, + required List actions, + }) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.updateInformationTemplateActions, + { + 'elementId': elementId, + 'actions': + actions.map((CPTextButton action) => action.toJson()).toList(), + }, + ); + + if (isCompleted == true) { + final template = FlutterCarPlayController.getTemplateFromHistory< + CPInformationTemplate>(elementId); + template?.updateActions(actions); + } + return; + } + + /// It will update the templates of the [CPTabBarTemplate] which has the given [elementId]. + /// Supported template types: [CPListTemplate], [CPPointOfInterestTemplate], + /// [CPGridTemplate], [CPInformationTemplate] + Future updateTabBarTemplates({ + required String elementId, + required List templates, + }) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.updateTabBarTemplates, + { + 'elementId': elementId, + 'templates': + templates.map((CPTemplate template) => template.toJson()).toList(), + }, + ); + + if (isCompleted == true) { + final template = + FlutterCarPlayController.getTemplateFromHistory( + elementId); + template?.updateTemplates(templates); + } + return; + } + + /// Getter for current root template. + /// Return one of type [CPTabBarTemplate], [CPGridTemplate], [CPListTemplate] + static dynamic get rootTemplate { + return FlutterCarPlayController.currentRootTemplate; + } + + /// It will present [CPAlertTemplate] modally. + /// + /// - template is to present modally. + /// - If animated is true, CarPlay animates the presentation of the template. + /// + /// [!] CarPlay can only present one modal template at a time. + static Future showAlert({ + required CPAlertTemplate template, + bool animated = true, + }) { + return FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.setAlert, + { + 'rootTemplate': template.toJson(), + 'animated': animated, + 'onPresent': template.onPresent != null ? true : false, + }, + ).then((value) { + if (value == true) { + FlutterCarPlayController.currentPresentTemplate = template; + } + }); + } + + /// It will present [CPActionSheetTemplate] modally. + /// + /// - template is to present modally. + /// - If animated is true, CarPlay animates the presentation of the template. + /// + /// [!] CarPlay can only present one modal template at a time. + static Future showActionSheet({ + required CPActionSheetTemplate template, + bool animated = true, + }) { + return FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.setActionSheet, + { + 'rootTemplate': template.toJson(), + 'animated': animated, + }, + ).then((value) { + if (value == true) { + FlutterCarPlayController.currentPresentTemplate = template; + } + }); + } + + /// Removes the top-most template from the navigation hierarchy. + /// + /// - If animated is true, CarPlay animates the transition between templates. + /// - count represents how many times this function will occur. + static Future pop({bool animated = true, int count = 1}) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.popTemplate, + {'count': count, 'animated': animated}, + ); + + return isCompleted ?? false; + } + + /// Removes all of the templates from the navigation hierarchy except the root template. + /// If animated is true, CarPlay animates the presentation of the template. + static Future popToRoot({bool animated = true}) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.popToRootTemplate, + animated, + ); + + return isCompleted ?? false; + } + + /// Removes a modal template. Since [CPAlertTemplate] and [CPActionSheetTemplate] are both + /// modals, they can be removed. If animated is true, CarPlay animates the transition between templates. + static Future popModal({bool animated = true}) async { + FlutterCarPlayController.currentPresentTemplate = null; + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.closePresent, + animated, + ); + + return isCompleted ?? false; + } + + /// Adds a template to the navigation hierarchy and displays it. + /// + /// - template is to add to the navigation hierarchy. **Must be one of the type:** + /// [CPGridTemplate] or [CPListTemplate] [CPInformationTemplate] [CPPointOfInterestTemplate] If not, it will throw an [TypeError] + /// + /// - If animated is true, CarPlay animates the transition between templates. + static Future push({ + required CPTemplate template, + bool animated = true, + }) async { + if (template is CPGridTemplate || + template is CPListTemplate || + template is CPInformationTemplate || + template is CPPointOfInterestTemplate || + template is CPSearchTemplate) { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.pushTemplate, + { + 'template': template.toJson(), + 'animated': animated, + }, + ); + if (isCompleted == true) { + _carPlayController.addTemplateToHistory(template); + } + return isCompleted ?? false; + } else { + throw TypeError(); + } + } + + /// Navigate to the shared instance of the NowPlaying Template + /// + /// - If animated is true, CarPlay animates the transition between templates. + static Future showSharedNowPlaying({bool animated = true}) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.showNowPlaying, + animated, + ); + return isCompleted ?? false; + } +} diff --git a/third_party/flutter_carplay/lib/constants/all.dart b/third_party/flutter_carplay/lib/constants/all.dart new file mode 100644 index 0000000..50da5ba --- /dev/null +++ b/third_party/flutter_carplay/lib/constants/all.dart @@ -0,0 +1,2 @@ +export 'constants.dart'; +export 'private_constants.dart'; diff --git a/third_party/flutter_carplay/lib/constants/constants.dart b/third_party/flutter_carplay/lib/constants/constants.dart new file mode 100644 index 0000000..621507e --- /dev/null +++ b/third_party/flutter_carplay/lib/constants/constants.dart @@ -0,0 +1 @@ +enum ConnectionStatusTypes { connected, background, disconnected, unknown } diff --git a/third_party/flutter_carplay/lib/constants/private_constants.dart b/third_party/flutter_carplay/lib/constants/private_constants.dart new file mode 100644 index 0000000..e9ac87f --- /dev/null +++ b/third_party/flutter_carplay/lib/constants/private_constants.dart @@ -0,0 +1,67 @@ +enum FCPChannelTypes { + onCarplayConnectionChange, + setRootTemplate, + forceUpdateRootTemplate, + updateListItem, + updateListImageRowItem, + updateListImageRowItemElement, + onFCPListItemSelected, + onFCPListItemSelectedComplete, + onFCPListImageRowItemSelected, + onFCPListImageRowItemSelectedComplete, + onFCPListImageRowItemElementSelected, + onFCPListImageRowItemElementSelectedComplete, + onFCPAlertActionPressed, + setAlert, + onPresentStateChanged, + popTemplate, + closePresent, + pushTemplate, + showNowPlaying, + onGridButtonPressed, + setActionSheet, + onBarButtonPressed, + onTextButtonPressed, + popToRootTemplate, + onScreenBackButtonPressed, + updateTabBarTemplates, + updateListTemplateSections, + updateInformationTemplateItems, + updateInformationTemplateActions, + getMaximumNumberOfGridImages, + getMaximumSectionCount, + getMaximumItemCount, + onSearchTextUpdated, + onSearchResultSelected, + onSearchButtonPressed, + updateSearchResults, + onSearchResultSelectedComplete, +} + +enum FAAChannelTypes { + onAndroidAutoConnectionChange, + setRootTemplate, + forceUpdateRootTemplate, + pushTemplate, + popTemplate, + popToRootTemplate, + updateListTemplateSections, + updatePaneTemplate, + onListItemSelected, + onListItemSelectedComplete, + onListSectionSelected, + onToggleCheckedChange, + onPaneActionPressed, + onMapActionPressed, + onScreenBackButtonPressed, + setAlert, + closePresent, + onAlertActionPressed, + onPresentStateChanged, + updateTabBarTemplates, + onTabBarItemSelected, + onGridButtonPressed, + onGridButtonSelectedComplete, + updateMessageTemplate, + updateLongMessageTemplate, +} diff --git a/third_party/flutter_carplay/lib/controllers/all.dart b/third_party/flutter_carplay/lib/controllers/all.dart new file mode 100644 index 0000000..f2c8998 --- /dev/null +++ b/third_party/flutter_carplay/lib/controllers/all.dart @@ -0,0 +1,2 @@ +export 'android_auto_controller.dart'; +export 'carplay_controller.dart'; diff --git a/third_party/flutter_carplay/lib/controllers/android_auto_controller.dart b/third_party/flutter_carplay/lib/controllers/android_auto_controller.dart new file mode 100644 index 0000000..f00ddad --- /dev/null +++ b/third_party/flutter_carplay/lib/controllers/android_auto_controller.dart @@ -0,0 +1,194 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_carplay/constants/private_constants.dart'; + +import '../aa_models/alert/alert_action.dart'; +import '../aa_models/alert/alert_template.dart'; +import '../aa_models/grid/grid_button.dart'; +import '../aa_models/list/list_item.dart'; +import '../aa_models/list/list_section.dart'; +import '../aa_models/list/list_template.dart'; +import '../aa_models/map/map_action.dart'; +import '../aa_models/pane/pane_action.dart'; +import '../aa_models/template.dart'; +import '../android_auto_worker.dart'; +import '../helpers/auto_android_helper.dart'; +import '../helpers/svg_rasterizer.dart'; + +/// [FlutterAndroidAutoController] is a root object used to control and +/// communicate with Android Auto native functions. +class FlutterAndroidAutoController { + static final FlutterAutoAndroidHelper _androidAutoHelper = + const FlutterAutoAndroidHelper(); + static final MethodChannel _methodChannel = MethodChannel( + _androidAutoHelper.makeFAAChannelId(), + ); + static final EventChannel _eventChannel = EventChannel( + _androidAutoHelper.makeFAAChannelId(event: '/event'), + ); + + /// [AATabBarTemplate], [AAGridTemplate], [AAListTemplate], [AAPaneTemplate], + /// [AAMessageTemplate], and [AALongMessageTemplate] in a list. + static List templateHistory = []; + + static AATemplate? get currentRootTemplate => templateHistory.firstOrNull; + + /// The currently presented modal, i.e. [AAAlertTemplate]. + static AATemplate? currentPresentTemplate; + + MethodChannel get methodChannel => _methodChannel; + + EventChannel get eventChannel => _eventChannel; + + Future flutterToNativeModule( + FAAChannelTypes type, [ + dynamic data, + ]) async { + return FlutterAndroidAutoController.flutterToNativeModuleStatic(type, data); + } + + static Future flutterToNativeModuleStatic( + FAAChannelTypes type, [ + dynamic data, + ]) async { + await resolveSvgInPayload(data, size: FlutterAndroidAuto.svgRasterSize); + final bool? value = await _methodChannel.invokeMethod( + type.name, + data, + ); + return value; + } + + static Future updateAAListTemplateSections({ + required String elementId, + required List sections, + }) async { + final payload = { + 'elementId': elementId, + 'sections': + sections.map((AAListSection section) => section.toJson()).toList(), + }; + + final bool? isCompleted = await flutterToNativeModuleStatic( + FAAChannelTypes.updateListTemplateSections, + payload, + ); + + if (isCompleted == true) { + for (final template in templateHistory) { + if (template is AAListTemplate && template.uniqueId == elementId) { + template.updateSections(sections); + return; + } + } + } + } + + Future processFAAListItemSelectedChannel(String elementId) async { + final AAListItem? item = _androidAutoHelper.findAAListItem( + templates: templateHistory, + elementId: elementId, + ); + if (item == null) return; + + Future complete() async { + await flutterToNativeModule( + FAAChannelTypes.onListItemSelectedComplete, + item.uniqueId, + ); + } + + try { + await Future.sync(() => item.onPress?.call(complete, item)); + } catch (_) { + await complete(); + } + } + + Future processFAAGridButtonPressed(String elementId) async { + final AAGridButton? item = _androidAutoHelper.findAAGridButton( + templates: templateHistory, + elementId: elementId, + ); + if (item == null) return; + + Future complete() async { + await flutterToNativeModule( + FAAChannelTypes.onGridButtonSelectedComplete, + item.uniqueId, + ); + } + + try { + await Future.sync(() => item.onPress?.call(complete, item)); + } catch (_) { + await complete(); + } + } + + void processFAAListSectionSelectedChannel( + String elementId, + int selectedIndex, + ) { + final AAListSection? listSection = _androidAutoHelper.findAAListSection( + templates: templateHistory, + elementId: elementId, + ); + final selectedItem = listSection?.items.elementAtOrNull(selectedIndex); + + if (listSection != null && selectedItem != null) { + listSection.selectedIndex = selectedIndex; + listSection.onSelected?.call(selectedIndex, selectedItem); + } + } + + void processFAAToggleCheckedChangeChannel(String elementId, bool checked) { + final AAListItem? listItem = _androidAutoHelper.findAAListItem( + templates: templateHistory, + elementId: elementId, + ); + + final toggle = listItem?.toggle; + if (toggle != null) { + toggle.isChecked = checked; + toggle.onCheckedChange?.call(checked, listItem!); + } + } + + void processFAAPaneActionPressedChannel(String elementId) { + final AAPaneAction? paneAction = _androidAutoHelper.findAAPaneAction( + templates: templateHistory, + elementId: elementId, + ); + paneAction?.onPress?.call(); + } + + void processFAAMapActionPressedChannel(String elementId) { + final AAMapAction? action = _androidAutoHelper.findAAMapAction( + templates: templateHistory, + elementId: elementId, + ); + action?.onPress?.call(); + } + + void processFAAAlertActionPressed(String elementId) { + final template = currentPresentTemplate; + if (template is! AAAlertTemplate) return; + + final AAAlertAction? action = + template.actions.cast().firstWhere( + (action) => action?.uniqueId == elementId, + orElse: () => null, + ); + action?.onPress(); + } + + void processFAAPresentStateChanged(String elementId, bool completed) { + final template = currentPresentTemplate; + if (template is AAAlertTemplate && template.onPresent != null) { + template.onPresent!(completed); + } + if (!completed) currentPresentTemplate = null; + } +} diff --git a/third_party/flutter_carplay/lib/controllers/carplay_controller.dart b/third_party/flutter_carplay/lib/controllers/carplay_controller.dart new file mode 100644 index 0000000..feefbd9 --- /dev/null +++ b/third_party/flutter_carplay/lib/controllers/carplay_controller.dart @@ -0,0 +1,443 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_carplay/flutter_carplay.dart'; + +/// [FlutterCarPlayController] is an root object in order to control and communication +/// system with the Apple CarPlay and native functions. +class FlutterCarPlayController { + static final FlutterCarplayHelper _carplayHelper = + const FlutterCarplayHelper(); + static final MethodChannel _methodChannel = MethodChannel( + _carplayHelper.makeFCPChannelId(), + ); + static final EventChannel _eventChannel = EventChannel( + _carplayHelper.makeFCPChannelId(event: '/event'), + ); + + /// [CPTabBarTemplate], [CPGridTemplate], [CPListTemplate], [CPIInformationTemplate], [CPPointOfInterestTemplate] in a List + static List templateHistory = []; + + /// [CPTabBarTemplate], [CPGridTemplate], [CPListTemplate], [CPIInformationTemplate], [CPPointOfInterestTemplate] + static CPTemplate? get currentRootTemplate => templateHistory.firstOrNull; + + /// [CPAlertTemplate], [CPActionSheetTemplate] + static CPTemplate? currentPresentTemplate; + + MethodChannel get methodChannel { + return _methodChannel; + } + + EventChannel get eventChannel { + return _eventChannel; + } + + static Future flutterToNativeModule( + FCPChannelTypes type, [ + dynamic data, + ]) async { + // Rasterize any Flutter asset SVGs referenced by image fields into PNG + // bytes before sending the payload to the native side, which cannot render + // SVG directly. Non-collection payloads pass through unchanged. + await resolveSvgInPayload(data, size: FlutterCarplay.svgRasterSize); + + final value = await _methodChannel.invokeMethod( + type.name, + data, + ); + return value; + } + + static void updateCPListItem( + CPListItem updatedListItem, + ) { + flutterToNativeModule( + FCPChannelTypes.updateListItem, + updatedListItem.toJson(), + ).then( + (value) { + if (value != true) return; + + for (var h in templateHistory) { + switch (h) { + case CPTabBarTemplate _: + for (var t in h.templates) { + if (t is CPListTemplate) { + for (var s in t.sections) { + for (var i in s.items) { + if (i.uniqueId == updatedListItem.uniqueId && + i is CPListItem) { + s.items[s.items.indexOf(i)] = updatedListItem; + return; + } + } + } + } + } + break; + case CPListTemplate _: + for (var s in h.sections) { + for (var i in s.items) { + if (i.uniqueId == updatedListItem.uniqueId && + i is CPListItem) { + s.items[s.items.indexOf(i)] = updatedListItem; + return; + } + } + } + break; + default: + } + } + }, + ); + } + + static void updateCPListImageRowItemElement( + CPListImageRowItemElement updatedListImageRowItemElement, + ) { + flutterToNativeModule( + FCPChannelTypes.updateListImageRowItemElement, + updatedListImageRowItemElement.toJson(), + ).then( + (value) { + if (value != true) return; + + for (var h in templateHistory) { + switch (h) { + case CPTabBarTemplate _: + for (var t in h.templates) { + if (t is CPListTemplate) { + for (var s in t.sections) { + for (var i in s.items) { + if (i is CPListImageRowItem) { + for (var e in i.elements ?? []) { + if (e.uniqueId == + updatedListImageRowItemElement.uniqueId) { + i.elements![i.elements!.indexOf(e)] = + updatedListImageRowItemElement; + return; + } + } + } + } + } + } + } + break; + case CPListTemplate _: + for (var s in h.sections) { + for (var i in s.items) { + if (i is CPListImageRowItem) { + for (var e in i.elements ?? []) { + if (e.uniqueId == + updatedListImageRowItemElement.uniqueId) { + i.elements![i.elements!.indexOf(e)] = + updatedListImageRowItemElement; + return; + } + } + } + } + } + break; + default: + } + } + }, + ); + } + + static void updateCPListImageRowItem( + CPListImageRowItem updatedListImageItem, + ) { + flutterToNativeModule( + FCPChannelTypes.updateListImageRowItem, + updatedListImageItem.toJson(), + ).then( + (value) { + if (value != true) return; + + for (var h in templateHistory) { + switch (h) { + case CPTabBarTemplate _: + for (var t in h.templates) { + if (t is CPListTemplate) { + for (var s in t.sections) { + for (var i in s.items) { + if (i.uniqueId == updatedListImageItem.uniqueId && + i is CPListImageRowItem) { + s.items[s.items.indexOf(i)] = updatedListImageItem; + return; + } + } + } + } + } + break; + case CPListTemplate _: + for (var s in h.sections) { + for (var i in s.items) { + if (i.uniqueId == updatedListImageItem.uniqueId && + i is CPListImageRowItem) { + s.items[s.items.indexOf(i)] = updatedListImageItem; + return; + } + } + } + break; + default: + } + } + }, + ); + } + + static Future getMaximumNumberOfGridImages() async { + final value = await _methodChannel.invokeMethod( + FCPChannelTypes.getMaximumNumberOfGridImages.name, + ); + return value; + } + + static Future getMaximumSectionCount() async { + final value = await _methodChannel.invokeMethod( + FCPChannelTypes.getMaximumSectionCount.name, + ); + return value; + } + + static Future getMaximumItemCount() async { + final value = await _methodChannel.invokeMethod( + FCPChannelTypes.getMaximumItemCount.name, + ); + return value; + } + + void addTemplateToHistory(CPTemplate template) { + if (template is CPTabBarTemplate || + template is CPGridTemplate || + template is CPInformationTemplate || + template is CPPointOfInterestTemplate || + template is CPListTemplate || + template is CPSearchTemplate) { + templateHistory.add(template); + } else { + throw TypeError(); + } + } + + Future processFCPListItemSelectedChannel(String elementId) async { + final item = _carplayHelper.findCPListTemplateItem( + templates: templateHistory, + elementId: elementId, + ); + if (item is! CPListItem) return; + + Future complete() async { + await flutterToNativeModule( + FCPChannelTypes.onFCPListItemSelectedComplete, item.uniqueId); + } + + try { + await Future.sync(() => item.onPress?.call(complete, item)); + } catch (_) { + await complete(); + } + } + + Future processFCPListImageRowItemSelectedChannel( + String elementId) async { + final item = _carplayHelper.findCPListTemplateItem( + templates: templateHistory, + elementId: elementId, + ); + + if (item is! CPListImageRowItem) return; + + Future complete() async { + await flutterToNativeModule( + FCPChannelTypes.onFCPListImageRowItemSelectedComplete, item.uniqueId); + } + + try { + await Future.sync(() => item.onPress?.call(complete, item)); + } catch (_) { + await complete(); + } + } + + Future processFCPListImageRowItemElementSelectedChannel( + String elementId, + int index, + ) async { + final item = _carplayHelper.findCPListTemplateItem( + templates: templateHistory, + elementId: elementId, + ); + + if (item is! CPListImageRowItem) return; + + Future complete() async { + await flutterToNativeModule( + FCPChannelTypes.onFCPListImageRowItemElementSelectedComplete, + item.uniqueId, + ); + } + + try { + await Future.sync(() => item.onItemPress?.call(complete, item, index)); + } catch (_) { + await complete(); + } + } + + void processFCPAlertActionPressed(String elementId) { + if (currentPresentTemplate is! CPActionsTemplate) return; + + final actions = (currentPresentTemplate as CPActionsTemplate).actions; + for (var action in actions) { + if (action.uniqueId == elementId) { + action.onPress(); + return; + } + } + } + + void processFCPAlertTemplateCompleted(bool completed) { + if (currentPresentTemplate is CPAlertTemplate) { + (currentPresentTemplate as CPAlertTemplate).onPresent?.call(completed); + } + } + + void processFCPGridButtonPressed(String elementId) { + CPGridButton? gridButton; + l1: + for (var t in templateHistory) { + if (t is CPGridTemplate) { + for (var b in t.buttons) { + if (b.uniqueId == elementId) { + gridButton = b; + break l1; + } + } + } + } + gridButton?.onPress?.call(); + } + + void processFCPBarButtonPressed(String elementId) { + for (var t in templateHistory) { + final List listTemplates = []; + if (t is CPTabBarTemplate) { + for (var template in t.templates) { + if (template is CPListTemplate) listTemplates.add(template); + } + } else if (t is CPListTemplate) { + listTemplates.add(t); + } + for (var list in listTemplates) { + if (list.backButton?.uniqueId == elementId) { + list.backButton?.onPress(); + return; + } + } + } + } + + void processFCPTextButtonPressed(String elementId) { + for (var t in templateHistory) { + if (t is CPPointOfInterestTemplate) { + for (CPPointOfInterest p in t.poi) { + if (p.primaryButton != null && + p.primaryButton!.uniqueId == elementId) { + p.primaryButton!.onPress(); + return; + } + if (p.secondaryButton != null && + p.secondaryButton!.uniqueId == elementId) { + p.secondaryButton!.onPress(); + return; + } + } + } else { + if (t is CPInformationTemplate) { + for (CPTextButton b in t.actions) { + if (b.uniqueId == elementId) { + b.onPress(); + return; + } + } + } + } + } + } + + void processFCPSearchTextUpdated(String elementId, String searchText) { + for (var t in templateHistory) { + if (t is CPSearchTemplate && t.uniqueId == elementId) { + t.onUpdatedSearchText?.call( + searchText, + (List results) { + t.updateResults(results); + final items = results.map((e) => e.toJson()).toList(); + FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.updateSearchResults, + { + 'elementId': elementId, + 'searchResults': items, + }, + ); + }, + ); + return; + } + } + } + + void processFCPSearchResultSelected(String elementId, String itemElementId) { + for (var t in templateHistory) { + if (t is CPSearchTemplate && t.uniqueId == elementId) { + CPListItem? selectedItem; + for (var item in t.currentResults) { + if (item.uniqueId == itemElementId) { + selectedItem = item; + break; + } + } + if (selectedItem != null) { + t.onSelectedResult?.call( + selectedItem, + () { + FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.onSearchResultSelectedComplete, + {'elementId': elementId}, + ); + }, + ); + } + return; + } + } + } + + void processFCPSearchButtonPressed(String elementId) { + for (var t in templateHistory) { + if (t is CPSearchTemplate && t.uniqueId == elementId) { + t.onSearchTemplateSearchButtonPressed?.call(); + return; + } + } + } + + static T? getTemplateFromHistory(String elementId) { + for (final template in templateHistory) { + if (template is T && template.uniqueId == elementId) return template; + + if (template is CPTabBarTemplate) { + for (final t in template.templates) { + if (t is T && t.uniqueId == elementId) return t; + } + } + } + return null; + } +} diff --git a/third_party/flutter_carplay/lib/flutter_carplay.dart b/third_party/flutter_carplay/lib/flutter_carplay.dart new file mode 100644 index 0000000..aea6278 --- /dev/null +++ b/third_party/flutter_carplay/lib/flutter_carplay.dart @@ -0,0 +1,16 @@ +// Android Auto & CarPlay Workers +export 'package:flutter_carplay/android_auto_worker.dart' + show FlutterAndroidAuto; +export 'package:flutter_carplay/carplay_worker.dart' show FlutterCarplay; + +// Constants +export 'package:flutter_carplay/constants/all.dart'; + +// Helpers +export 'package:flutter_carplay/helpers/all.dart'; + +// Models +export 'package:flutter_carplay/models/all.dart'; + +// Android Auto Models +export 'package:flutter_carplay/aa_models/all.dart'; diff --git a/third_party/flutter_carplay/lib/helpers/all.dart b/third_party/flutter_carplay/lib/helpers/all.dart new file mode 100644 index 0000000..ba2f0ba --- /dev/null +++ b/third_party/flutter_carplay/lib/helpers/all.dart @@ -0,0 +1,4 @@ +export 'auto_android_helper.dart'; +export 'carplay_helper.dart'; +export 'enum_utils.dart'; +export 'svg_rasterizer.dart'; diff --git a/third_party/flutter_carplay/lib/helpers/auto_android_helper.dart b/third_party/flutter_carplay/lib/helpers/auto_android_helper.dart new file mode 100644 index 0000000..bb1ee07 --- /dev/null +++ b/third_party/flutter_carplay/lib/helpers/auto_android_helper.dart @@ -0,0 +1,103 @@ +import 'package:flutter_carplay/flutter_carplay.dart'; + +class FlutterAutoAndroidHelper { + const FlutterAutoAndroidHelper(); + + AAListItem? findAAListItem({ + required List templates, + required String elementId, + }) { + for (final template in templates) { + for (final listTemplate in _listTemplates(template)) { + for (final section in listTemplate.sections) { + for (final item in section.items) { + if (item.uniqueId == elementId) return item; + } + } + } + } + return null; + } + + AAListSection? findAAListSection({ + required List templates, + required String elementId, + }) { + for (final template in templates) { + for (final listTemplate in _listTemplates(template)) { + for (final section in listTemplate.sections) { + if (section.uniqueId == elementId) return section; + } + } + } + return null; + } + + AAGridButton? findAAGridButton({ + required List templates, + required String elementId, + }) { + for (final template in templates) { + for (final gridTemplate in _gridTemplates(template)) { + for (final button in gridTemplate.buttons) { + if (button.uniqueId == elementId) return button; + } + } + } + return null; + } + + AAPaneAction? findAAPaneAction({ + required List templates, + required String elementId, + }) { + for (final template in templates) { + if (template is AAPaneTemplate) { + for (final action in template.actions) { + if (action.uniqueId == elementId) return action; + } + } + } + return null; + } + + /// DELTA D: the map action strip's actions live on the map template itself, + /// not on the content template nested inside it, so the pane lookup above + /// cannot find them. + AAMapAction? findAAMapAction({ + required List templates, + required String elementId, + }) { + for (final template in templates) { + if (template is AAMapWithContentTemplate) { + for (final action in template.mapActions) { + if (action.uniqueId == elementId) return action; + } + } + } + return null; + } + + Iterable _listTemplates(AATemplate template) sync* { + if (template is AAListTemplate) { + yield template; + } else if (template is AATabBarTemplate) { + for (final tab in template.tabs) { + if (tab is AAListTemplate) yield tab; + } + } + } + + Iterable _gridTemplates(AATemplate template) sync* { + if (template is AAGridTemplate) { + yield template; + } else if (template is AATabBarTemplate) { + for (final tab in template.tabs) { + if (tab is AAGridTemplate) yield tab; + } + } + } + + String makeFAAChannelId({String event = ''}) => + 'com.oguzhnatly.flutter_android_auto$event'; +} diff --git a/third_party/flutter_carplay/lib/helpers/carplay_helper.dart b/third_party/flutter_carplay/lib/helpers/carplay_helper.dart new file mode 100644 index 0000000..8bee09e --- /dev/null +++ b/third_party/flutter_carplay/lib/helpers/carplay_helper.dart @@ -0,0 +1,41 @@ +import 'package:flutter_carplay/flutter_carplay.dart'; + +class FlutterCarplayHelper { + const FlutterCarplayHelper(); + + CPListTemplateItem? findCPListTemplateItem({ + required List templates, + required String elementId, + }) { + CPListTemplateItem? listItem; + l1: + for (var t in templates) { + final List listTemplates = []; + if (t is CPTabBarTemplate) { + for (var template in t.templates) { + if (template is CPListTemplate) { + listTemplates.add(template); + } + } + } else if (t is CPListTemplate) { + listTemplates.add(t); + } + if (listTemplates.isNotEmpty) { + for (var list in listTemplates) { + for (var section in list.sections) { + for (var item in section.items) { + if (item.uniqueId == elementId) { + listItem = item; + break l1; + } + } + } + } + } + } + return listItem; + } + + String makeFCPChannelId({String? event = ''}) => + 'com.oguzhnatly.flutter_carplay${event!}'; +} diff --git a/third_party/flutter_carplay/lib/helpers/enum_utils.dart b/third_party/flutter_carplay/lib/helpers/enum_utils.dart new file mode 100644 index 0000000..7057207 --- /dev/null +++ b/third_party/flutter_carplay/lib/helpers/enum_utils.dart @@ -0,0 +1,9 @@ +class EnumUtils { + const EnumUtils._(); + + static T enumFromString(Iterable values, String string) { + return values.firstWhere( + (T f) => f.name.toUpperCase() == string.toUpperCase(), + ); + } +} diff --git a/third_party/flutter_carplay/lib/helpers/svg_rasterizer.dart b/third_party/flutter_carplay/lib/helpers/svg_rasterizer.dart new file mode 100644 index 0000000..6091747 --- /dev/null +++ b/third_party/flutter_carplay/lib/helpers/svg_rasterizer.dart @@ -0,0 +1,245 @@ +import 'dart:convert'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_svg/flutter_svg.dart'; + +/// Default raster size (in logical pixels, square) used when an SVG asset is +/// rasterized to PNG bytes for native consumption. +/// +/// This can be overridden globally via [FlutterCarplay.svgRasterSize] / +/// [FlutterAndroidAuto.svgRasterSize], which are forwarded to +/// [resolveSvgInPayload]. +const defaultSvgRasterSize = 120; + +/// In-memory cache of rasterized SVG assets keyed by `assetPath|size`. +final _svgRasterCache = {}; + +/// In-flight rasterization operations keyed by `assetPath|size`. Concurrent +/// requests for the same asset/size share a single operation instead of +/// rasterizing the same SVG multiple times. +final _svgRasterInflight = >{}; + +/// Clears the in-memory rasterized SVG cache. +/// +/// Primarily intended for tests, but safe to call at any time. +@visibleForTesting +void clearSvgRasterCache() => _svgRasterCache.clear(); + +/// Returns `true` when [value] points to a Flutter asset SVG. +/// +/// Two conditions must hold: the value ends with `.svg` (case-insensitive) and +/// it is not a remote (`http`/`https`) URL. +bool isSvgAsset(String? value) { + final lower = value?.trim().toLowerCase(); + if (lower == null) return false; + return lower.endsWith('.svg') && !lower.startsWith('http'); +} + +/// Rasterizes the Flutter asset SVG at [assetPath] into PNG bytes. +/// +/// The output is a square image of [size] x [size] logical pixels. Results are +/// cached in-memory keyed by `assetPath|size`, so repeated calls return the +/// same [Uint8List] instance. Concurrent calls for the same asset/size share a +/// single in-flight operation. +/// +/// Returns `null` when the asset cannot be loaded or rasterized (e.g. invalid +/// SVG, missing asset). +Future rasterizeSvgAsset( + String assetPath, { + int size = defaultSvgRasterSize, +}) { + final cacheKey = '$assetPath|$size'; + + final cached = _svgRasterCache[cacheKey]; + if (cached != null) return Future.value(cached); + + final inflight = _svgRasterInflight[cacheKey]; + if (inflight != null) return inflight; + + final operation = _rasterize(assetPath, size, cacheKey); + _svgRasterInflight[cacheKey] = operation; + return operation.whenComplete(() => _svgRasterInflight.remove(cacheKey)); +} + +/// Performs the actual rasterization for [rasterizeSvgAsset]. +Future _rasterize( + String assetPath, + int size, + String cacheKey, +) async { + try { + // Load the asset bytes ourselves (rather than via [SvgAssetLoader]) so that + // a missing asset surfaces here as a catchable error instead of going + // through flutter_svg's asset cache. + final assetData = await rootBundle.load(assetPath); + final svgString = utf8.decode(assetData.buffer.asUint8List()); + + final loader = SvgStringLoader(svgString); + final pictureInfo = await vg.loadPicture(loader, null); + + try { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + + final pictureSize = pictureInfo.size; + final sourceWidth = pictureSize.width.isFinite && pictureSize.width > 0 + ? pictureSize.width + : size.toDouble(); + final sourceHeight = pictureSize.height.isFinite && pictureSize.height > 0 + ? pictureSize.height + : size.toDouble(); + + // Scale uniformly to fit within the target square while preserving the + // aspect ratio, then center within the [size] x [size] canvas. + final scale = (size / sourceWidth) < (size / sourceHeight) + ? size / sourceWidth + : size / sourceHeight; + final dx = (size - sourceWidth * scale) / 2; + final dy = (size - sourceHeight * scale) / 2; + + canvas.translate(dx, dy); + canvas.scale(scale); + canvas.drawPicture(pictureInfo.picture); + + final rendered = recorder.endRecording(); + final image = await rendered.toImage(size, size); + try { + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) return null; + final bytes = byteData.buffer.asUint8List(); + _svgRasterCache[cacheKey] = bytes; + return bytes; + } finally { + image.dispose(); + rendered.dispose(); + } + } finally { + pictureInfo.picture.dispose(); + } + } catch (error, stackTrace) { + debugPrint('flutter_carplay: failed to rasterize SVG "$assetPath": $error'); + debugPrintStack(stackTrace: stackTrace); + return null; + } +} + +/// Every payload key that may reference a Flutter asset SVG, paired with how the +/// rasterized bytes are attached. +/// +/// This is the single source of truth for the walker. When a model gains a new +/// image-bearing `toJson()` key, add it here. The coverage test +/// (`test/helpers/svg_rasterizer_coverage_test.dart`) asserts that every model +/// image key is represented here so new keys cannot silently slip through. +/// +/// Models emitting these keys: +/// - `image` -> CPListItem, CPGridButton, CPPointOfInterest, and all +/// CPListImageRowItem*Element subtypes. Bytes are attached +/// under `imageData`. +/// - `imageUrl` -> AAListItem (Android Auto). The native contract expects the +/// bytes under `imageData`. +/// - `accessoryImage` / `trailingImage` -> CPListItem trailing/accessory image. +/// Bytes are attached under `trailingImageData`. +/// - `gridImages`-> CPListImageRowItem (legacy iOS grid images); the native +/// contract expects the bytes under `gridImageData`. +@visibleForTesting +const svgImageDataKeys = { + 'image': 'imageData', + 'imageUrl': 'imageData', + 'accessoryImage': 'trailingImageData', + 'trailingImage': 'trailingImageData', + 'gridImages': 'gridImageData', +}; + +/// Image keys whose values are lists rather than a single asset string. +@visibleForTesting +const svgListImageKeys = {'gridImages'}; + +/// Keys that look image-related but must never be rasterized. +/// +/// - `systemIcon` -> CPTemplate tab image (resolved natively as an SF Symbol / +/// tab image, not an asset SVG we rasterize). +/// - `imageTitles` -> CPListImageRowItem labels (text, not images). +/// - `imageTint`/`trailingImageTint`/`gridImageTints` -> tint metadata, not +/// image references. +@visibleForTesting +const svgIgnoredKeys = { + 'systemIcon', + 'imageTitles', + 'imageTint', + 'trailingImageTint', + 'gridImageTints', +}; + +/// The sibling keys under which the walker attaches rasterized bytes (e.g. +/// `imageData`, `gridImageData`). These hold raw byte payloads, so the walker +/// must never recurse into them. +final _svgDataKeys = svgImageDataKeys.values.toSet(); + +/// Recursively walks a method-channel [node] (maps/lists), rasterizing any +/// Flutter asset SVG referenced by an image-bearing key (see [svgImageDataKeys]) +/// and attaching the PNG bytes to a sibling `Data` key. +/// +/// Behavior: +/// - A single-image key (e.g. `image`, `imageUrl`) whose value is an SVG asset +/// -> sibling `Data` ([Uint8List]). +/// - A list-image key (e.g. `gridImages`) -> sibling `Data` +/// (`List`, `null` for non-SVG entries). +/// - Keys in [svgIgnoredKeys] are skipped entirely (never rasterized, never +/// recursed into). +/// - Original image strings are preserved for native fallback / back-compat. +/// +/// The [node] is mutated in place and also returned for convenience. +Future resolveSvgInPayload( + dynamic node, { + int size = defaultSvgRasterSize, +}) async { + if (node is Map) { + for (final entry in svgImageDataKeys.entries) { + final value = node[entry.key]; + if (svgListImageKeys.contains(entry.key)) { + if (value is! List) continue; + var hasSvg = false; + final data = []; + for (final item in value) { + final bytes = await _rasterizeIfSvg(item, size); + data.add(bytes); + if (bytes != null) hasSvg = true; + } + if (hasSvg) node[entry.value] = data; + } else { + final bytes = await _rasterizeIfSvg(value, size); + if (bytes != null) node[entry.value] = bytes; + } + } + + // Recurse into all values, skipping ignored keys and the byte payloads we + // just attached. The latter are raster bytes ([Uint8List], which is itself + // a `List`) or lists of them; descending into those would needlessly + // walk every individual byte. + for (final key in node.keys.toList()) { + if (svgIgnoredKeys.contains(key)) continue; + if (_svgDataKeys.contains(key)) continue; + final value = node[key]; + if (value is Uint8List) continue; + await resolveSvgInPayload(value, size: size); + } + } else if (node is List) { + for (final item in node) { + if (item is Uint8List) continue; + await resolveSvgInPayload(item, size: size); + } + } + + return node; +} + +/// Returns rasterized PNG bytes when [value] is a Flutter asset SVG string, +/// otherwise `null`. +Future _rasterizeIfSvg(dynamic value, int size) { + if (value is String && isSvgAsset(value)) { + return rasterizeSvgAsset(value, size: size); + } + return Future.value(); +} diff --git a/third_party/flutter_carplay/lib/models/action_sheet/action_sheet_template.dart b/third_party/flutter_carplay/lib/models/action_sheet/action_sheet_template.dart new file mode 100644 index 0000000..d7d4ec0 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/action_sheet/action_sheet_template.dart @@ -0,0 +1,53 @@ +import 'package:flutter_carplay/models/alert/alert_action.dart'; +import 'package:uuid/uuid.dart'; + +import '../template.dart'; + +/// A template that displays a modal action sheet. +/// https://developer.apple.com/documentation/carplay/cpactionsheettemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPActionSheetTemplate extends CPTemplate implements CPActionsTemplate { + /// Unique id of the object. + final String _elementId; + + /// The title of the action sheet. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String? title; + + /// The descriptive message providing details about the reason for displaying the action sheet. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String? message; + + /// The list of actions available on the action sheet. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + @override + final List actions; + + /// Creates [CPActionSheetTemplate] + CPActionSheetTemplate({ + this.title, + this.message, + required this.actions, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'message': message, + 'actions': actions.map((e) => e.toJson()).toList(), + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPActionSheetTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/action_sheet/all.dart b/third_party/flutter_carplay/lib/models/action_sheet/all.dart new file mode 100644 index 0000000..5236d18 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/action_sheet/all.dart @@ -0,0 +1 @@ +export 'action_sheet_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/alert/alert_action.dart b/third_party/flutter_carplay/lib/models/alert/alert_action.dart new file mode 100644 index 0000000..a30df54 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/alert/alert_action.dart @@ -0,0 +1,42 @@ +import 'package:flutter_carplay/models/alert/alert_constants.dart'; +import 'package:uuid/uuid.dart'; + +/// An object that encapsulates an action the user can perform on an action sheet or alert. +/// https://developer.apple.com/documentation/carplay/cpalertaction +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPAlertAction { + /// Unique id of the object. + final String _elementId; + + /// The action button’s title. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String title; + + /// The display style for the action button. + /// Default is [CPAlertActionStyle.normal] + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final CPAlertActionStyle style; + + /// The closure that CarPlay invokes after the user taps the action button. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function() onPress; + + /// Creates [CPAlertAction] + CPAlertAction({ + required this.title, + this.style = CPAlertActionStyle.normal, + required this.onPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'style': style.name, + 'runtimeType': 'FCPAlertAction', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/alert/alert_constants.dart b/third_party/flutter_carplay/lib/models/alert/alert_constants.dart new file mode 100644 index 0000000..ea50a30 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/alert/alert_constants.dart @@ -0,0 +1,3 @@ +/// Display styles for an alert’s action button. +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +enum CPAlertActionStyle { normal, cancel, destructive } diff --git a/third_party/flutter_carplay/lib/models/alert/alert_template.dart b/third_party/flutter_carplay/lib/models/alert/alert_template.dart new file mode 100644 index 0000000..2401ca7 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/alert/alert_template.dart @@ -0,0 +1,55 @@ +import 'package:flutter_carplay/models/alert/alert_action.dart'; +import 'package:uuid/uuid.dart'; + +import '../template.dart'; + +/// A template that displays a modal alert. +/// https://developer.apple.com/documentation/carplay/cpalerttemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPAlertTemplate extends CPTemplate implements CPActionsTemplate { + /// Unique id of the object. + final String _elementId; + + /// The array of title variants. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final List titleVariants; + + /// The array of actions available on the alert. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + @override + final List actions; + + /// The closure that CarPlay invokes after the user taps the action button. + /// Notes: + /// - If completed is true, the alert successfully presented. If not, you may want to show an error to the user. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function(bool completed)? onPresent; + + /// Creates [CPAlertTemplate] + CPAlertTemplate({ + required this.titleVariants, + required this.actions, + this.onPresent, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'titleVariants': titleVariants, + 'actions': actions.map((e) => e.toJson()).toList(), + 'onPresent': onPresent != null ? true : false, + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPAlertTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/alert/all.dart b/third_party/flutter_carplay/lib/models/alert/all.dart new file mode 100644 index 0000000..5f9cd4e --- /dev/null +++ b/third_party/flutter_carplay/lib/models/alert/all.dart @@ -0,0 +1,3 @@ +export 'alert_action.dart'; +export 'alert_constants.dart'; +export 'alert_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/all.dart b/third_party/flutter_carplay/lib/models/all.dart new file mode 100644 index 0000000..2ad9241 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/all.dart @@ -0,0 +1,11 @@ +export 'action_sheet/all.dart'; +export 'alert/all.dart'; +export 'button/all.dart'; +export 'common/all.dart'; +export 'grid/all.dart'; +export 'information/all.dart'; +export 'list/all.dart'; +export 'poi/all.dart'; +export 'search/all.dart'; +export 'tabbar/all.dart'; +export 'template.dart'; diff --git a/third_party/flutter_carplay/lib/models/button/all.dart b/third_party/flutter_carplay/lib/models/button/all.dart new file mode 100644 index 0000000..2081529 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/button/all.dart @@ -0,0 +1,3 @@ +export 'bar_button.dart'; +export 'button_constants.dart'; +export 'text_button.dart'; diff --git a/third_party/flutter_carplay/lib/models/button/bar_button.dart b/third_party/flutter_carplay/lib/models/button/bar_button.dart new file mode 100644 index 0000000..f339764 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/button/bar_button.dart @@ -0,0 +1,43 @@ +import 'package:uuid/uuid.dart'; + +import 'button_constants.dart'; + +/// A button for placement in a navigation bar. +/// https://developer.apple.com/documentation/carplay/cpbarbutton +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPBarButton { + /// Unique id of the object. + final String _elementId; + + /// The title displayed on the bar button. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String title; + + /// The style to use when displaying the button. + /// Default is [CPBarButtonStyle.rounded] + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final CPBarButtonStyle buttonStyle; + + /// A block that CarPlay calls when the user taps a bar button. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final Function() onPress; + + /// Creates [CPBarButton] + CPBarButton({ + required this.title, + this.buttonStyle = CPBarButtonStyle.rounded, + required this.onPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'buttonStyle': buttonStyle.name, + 'runtimeType': 'FCPBarButton', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/button/button_constants.dart b/third_party/flutter_carplay/lib/models/button/button_constants.dart new file mode 100644 index 0000000..794068d --- /dev/null +++ b/third_party/flutter_carplay/lib/models/button/button_constants.dart @@ -0,0 +1,5 @@ +/// The display style of a bar button. +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +enum CPBarButtonStyle { none, rounded } + +enum CPTextButtonStyle { normal, cancel, confirm } diff --git a/third_party/flutter_carplay/lib/models/button/text_button.dart b/third_party/flutter_carplay/lib/models/button/text_button.dart new file mode 100644 index 0000000..6b3fa97 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/button/text_button.dart @@ -0,0 +1,43 @@ +import 'package:uuid/uuid.dart'; + +import 'button_constants.dart'; + +/// A button that displays a stylized title. +/// https://developer.apple.com/documentation/carplay/CPTextButton +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPTextButton { + /// Unique id of the object. + final String _elementId; + + /// The text the button displays. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String title; + + /// The text style the button applies to its title. + /// Default is [CPTextButtonStyle.normal] + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final CPTextButtonStyle textstyle; + + /// A closure that CarPlay invokes when the user taps the button. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final Function() onPress; + + /// Creates [CPTextButton] + CPTextButton({ + required this.title, + this.textstyle = CPTextButtonStyle.normal, + required this.onPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'textstyle': textstyle.name, + 'runtimeType': 'FCPTextButton', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/common/all.dart b/third_party/flutter_carplay/lib/models/common/all.dart new file mode 100644 index 0000000..e463f94 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/common/all.dart @@ -0,0 +1,2 @@ +export 'image_tint.dart'; +export 'ui_color.dart'; diff --git a/third_party/flutter_carplay/lib/models/common/image_tint.dart b/third_party/flutter_carplay/lib/models/common/image_tint.dart new file mode 100644 index 0000000..8236248 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/common/image_tint.dart @@ -0,0 +1,130 @@ +import 'ui_color.dart'; + +/// Host-aware tint options for image glyphs shown in CarPlay and Android Auto. +/// +/// Prefer [AutoImageTint.platform] for icons that must remain visible in focused +/// or selected rows. It lets the host choose an appropriate color on Android +/// Auto and uses a high-contrast system color on CarPlay. +/// +/// Custom colors use [UIColor] RGB channels authored as byte values from `0` to +/// `255`. Native platforms convert those byte values internally, so callers do +/// not need to account for UIKit's normalized color component range. +class AutoImageTint { + final AutoImageTintType type; + + /// Light-mode custom color. Only used by [AutoImageTint.custom]. + final UIColor? color; + + /// Dark-mode custom color. Falls back to [color] when omitted. + final UIColor? darkColor; + + /// Adds contrast protection where the platform does not manage selected-state + /// icon contrast for us. Currently this is applied by the CarPlay renderer. + final bool selectedSafe; + + const AutoImageTint._({ + required this.type, + this.color, + this.darkColor, + this.selectedSafe = true, + }); + + /// Uses the host platform's default icon tint. + const AutoImageTint.platform({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.platform, + selectedSafe: selectedSafe, + ); + + /// Uses the host platform's primary tint color. + const AutoImageTint.primary({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.primary, + selectedSafe: selectedSafe, + ); + + /// Uses the host platform's secondary tint color. + const AutoImageTint.secondary({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.secondary, + selectedSafe: selectedSafe, + ); + + /// Uses a platform-standard red tint. + const AutoImageTint.red({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.red, + selectedSafe: selectedSafe, + ); + + /// Uses a platform-standard green tint. + const AutoImageTint.green({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.green, + selectedSafe: selectedSafe, + ); + + /// Uses a platform-standard blue tint. + const AutoImageTint.blue({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.blue, + selectedSafe: selectedSafe, + ); + + /// Uses a platform-standard yellow tint. + const AutoImageTint.yellow({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.yellow, + selectedSafe: selectedSafe, + ); + + /// Uses custom RGB byte colors for light and optional dark mode. + /// + /// Pass [color] and [darkColor] as [UIColor] values with RGB channels from + /// `0` to `255`. If [darkColor] is omitted, [color] is reused in dark mode. + const AutoImageTint.custom({ + required UIColor color, + UIColor? darkColor, + bool selectedSafe = true, + }) : this._( + type: AutoImageTintType.custom, + color: color, + darkColor: darkColor, + selectedSafe: selectedSafe, + ); + + Map toJson() => { + 'type': type.name, + 'color': color?.toJson(), + 'darkColor': darkColor?.toJson(), + 'selectedSafe': selectedSafe, + }; +} + +/// Convenience helpers for configuring [AutoImageTint]. +extension AutoImageTintDarkColor on AutoImageTint { + /// Returns a copy of this tint with [darkColor] as its dark-mode custom color. + /// + /// [darkColor] is authored with RGB byte channels from `0` to `255`. Native + /// platforms convert those values internally. For non-custom tint types this + /// method creates a custom tint, using the provided [darkColor] for both light + /// and dark mode unless the original tint already had a custom light [color]. + AutoImageTint withDarkColor(UIColor darkColor) { + return AutoImageTint.custom( + color: color ?? darkColor, + darkColor: darkColor, + selectedSafe: selectedSafe, + ); + } +} + +enum AutoImageTintType { + platform, + primary, + secondary, + red, + green, + blue, + yellow, + custom, +} diff --git a/third_party/flutter_carplay/lib/models/common/ui_color.dart b/third_party/flutter_carplay/lib/models/common/ui_color.dart new file mode 100644 index 0000000..4d0214e --- /dev/null +++ b/third_party/flutter_carplay/lib/models/common/ui_color.dart @@ -0,0 +1,43 @@ +/// An object that stores color data and sometimes opacity. +/// +/// Pass RGB channels as byte values from `0` to `255`. Native platforms convert +/// those byte channels internally to the format they need, such as UIKit's +/// normalized `0.0` to `1.0` components. +/// +/// [alpha] accepts the existing normalized `0.0` to `1.0` range, and native +/// platforms also tolerate byte-style alpha values from `0` to `255`. +/// iOS 2.0+ | iPadOS 2.0+ | Mac Catalyst 13.1+ +class UIColor { + /// Red channel, authored as a byte value from `0` to `255`. + final int red; + + /// Green channel, authored as a byte value from `0` to `255`. + final int green; + + /// Blue channel, authored as a byte value from `0` to `255`. + final int blue; + + /// Opacity. Prefer `0.0` to `1.0`; native platforms also accept `0` to `255`. + final double alpha; + + /// Creates [UIColor]. + /// + /// RGB values outside `0` to `255` are clamped when serialized. + const UIColor({ + required this.red, + required this.green, + required this.blue, + this.alpha = 1.0, + }); + + Map toJson() { + return { + 'red': _clampByte(red), + 'green': _clampByte(green), + 'blue': _clampByte(blue), + 'alpha': alpha, + }; + } + + static int _clampByte(int value) => value.clamp(0, 255).toInt(); +} diff --git a/third_party/flutter_carplay/lib/models/grid/all.dart b/third_party/flutter_carplay/lib/models/grid/all.dart new file mode 100644 index 0000000..2a5672a --- /dev/null +++ b/third_party/flutter_carplay/lib/models/grid/all.dart @@ -0,0 +1,2 @@ +export 'grid_button.dart'; +export 'grid_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/grid/grid_button.dart b/third_party/flutter_carplay/lib/models/grid/grid_button.dart new file mode 100644 index 0000000..1bfaab6 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/grid/grid_button.dart @@ -0,0 +1,62 @@ +import 'package:uuid/uuid.dart'; + +import '../common/image_tint.dart'; + +/// A menu item button displayed on a grid template. +/// https://developer.apple.com/documentation/carplay/cpgridbutton +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPGridButton { + /// Unique id of the object. + final String _elementId; + + /// An array of title variants for the button. + /// When the system displays the button, it selects the title that best fits the available + /// screen space, so arrange the titles from most to least preferred when creating a grid button. + /// Also, localize each title for display to the user, and **be sure to include at least + /// one title in the array.** + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final List titleVariants; + + /// The image displayed on the button. + /// + /// Supports these formats: + /// - **Asset path**: `images/flutter_logo.png` (from pubspec.yaml assets) + /// - **SVG asset**: `images/icon.svg` (rasterized to PNG before being sent to + /// the native side; remote/`file://` SVGs are not supported) + /// - **File path**: `file:///path/to/image.png` (local file on device) + /// - **Network URL**: `https://example.com/image.png` (remote image) + /// + /// **[!] When creating a grid button, do NOT provide an animated image. If you do, the button + /// uses the first image in the animation sequence.** + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String image; + + /// Optional tint applied to [image]. + final AutoImageTint? imageTint; + + /// The block invoked after the user taps the button. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function()? onPress; + + /// Creates [CPGridButton] + CPGridButton({ + required this.titleVariants, + required this.image, + this.imageTint, + this.onPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'titleVariants': titleVariants, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'onPress': onPress != null ? true : false, + 'runtimeType': 'FCPGridButton', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/grid/grid_template.dart b/third_party/flutter_carplay/lib/models/grid/grid_template.dart new file mode 100644 index 0000000..fd04838 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/grid/grid_template.dart @@ -0,0 +1,50 @@ +import 'package:flutter_carplay/models/grid/grid_button.dart'; +import 'package:uuid/uuid.dart'; + +import '../template.dart'; + +/// Creates a grid template with a title and a set of buttons. +/// https://developer.apple.com/documentation/carplay/cpgridtemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPGridTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// The title shown in the grid template’s navigation bar. + /// [systemIcon] must be set in order for the title to be displayed in a tab bar. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String title; + + /// The array of grid buttons displayed on the template. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final List buttons; + + /// Creates [CPGridTemplate] in order to display a grid of items as buttons. + /// When creating the grid template, provide an array of [CPGridButton] objects. + /// Each button must contain a title that is shown in the grid template's navigation bar. + CPGridTemplate({ + required this.title, + required this.buttons, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + super.onPop, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'buttons': buttons.map((e) => e.toJson()).toList(), + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPGridTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/information/all.dart b/third_party/flutter_carplay/lib/models/information/all.dart new file mode 100644 index 0000000..525bcfa --- /dev/null +++ b/third_party/flutter_carplay/lib/models/information/all.dart @@ -0,0 +1,3 @@ +export 'information_constants.dart'; +export 'information_item.dart'; +export 'information_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/information/information_constants.dart b/third_party/flutter_carplay/lib/models/information/information_constants.dart new file mode 100644 index 0000000..aa5e4f2 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/information/information_constants.dart @@ -0,0 +1,3 @@ +/// The layout that an information template uses to arrange its items. +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +enum CPInformationTemplateLayout { leading, twoColumn } diff --git a/third_party/flutter_carplay/lib/models/information/information_item.dart b/third_party/flutter_carplay/lib/models/information/information_item.dart new file mode 100644 index 0000000..da13fca --- /dev/null +++ b/third_party/flutter_carplay/lib/models/information/information_item.dart @@ -0,0 +1,35 @@ +import 'package:uuid/uuid.dart'; + +/// A data object that provides content for an information template. +/// https://developer.apple.com/documentation/carplay/cpinformationitem +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPInformationItem { + /// Unique id of the object. + final String _elementId; + + /// The text that the template displays as the item’s title. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String? title; + + /// The text that the template displays below or beside the item’s title. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String? detail; + + /// Creates [CPInformationItem] + CPInformationItem({ + this.title, + this.detail, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'detail': detail, + 'runtimeType': 'FCPInformationItem', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/information/information_template.dart b/third_party/flutter_carplay/lib/models/information/information_template.dart new file mode 100644 index 0000000..ebcc383 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/information/information_template.dart @@ -0,0 +1,75 @@ +import 'package:uuid/uuid.dart'; + +import '../button/text_button.dart'; +import '../template.dart'; +import 'information_constants.dart'; +import 'information_item.dart'; + +/// A template object that displays and manages information items and text buttons. +/// https://developer.apple.com/documentation/carplay/cpinformationtemplate +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPInformationTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// The template’s title. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String title; + + /// The layout that the template uses to arrange its items. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final CPInformationTemplateLayout layout; + + /// The actions that the template displays. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List actions; + + /// An array of information items that the template displays. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List informationItems; + + /// Creates [CPInformationTemplate] + CPInformationTemplate({ + required this.title, + required this.layout, + required this.actions, + required this.informationItems, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + super.onPop, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'layout': layout.name, + 'title': title, + 'actions': actions.map((e) => e.toJson()).toList(), + 'informationItems': informationItems.map((e) => e.toJson()).toList(), + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPInformationTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } + + void updateInformationItems(List newItems) { + final copy = List.from(newItems); + informationItems + ..clear() + ..addAll(copy); + } + + void updateActions(List newActions) { + final copy = List.from(newActions); + actions + ..clear() + ..addAll(copy); + } +} diff --git a/third_party/flutter_carplay/lib/models/list/all.dart b/third_party/flutter_carplay/lib/models/list/all.dart new file mode 100644 index 0000000..d2ccd78 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/all.dart @@ -0,0 +1,7 @@ +export 'list_constants.dart'; +export 'list_image_row_item.dart'; +export 'list_image_row_item/all.dart'; +export 'list_item.dart'; +export 'list_section.dart'; +export 'list_template.dart'; +export 'list_template_item.dart'; diff --git a/third_party/flutter_carplay/lib/models/list/list_constants.dart b/third_party/flutter_carplay/lib/models/list/list_constants.dart new file mode 100644 index 0000000..ef7429d --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_constants.dart @@ -0,0 +1,7 @@ +/// The locations where a list item can display the Now Playing indicator. +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +enum CPListItemPlayingIndicatorLocation { trailing, leading } + +/// The accessory types that a list item can display. +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +enum CPListItemAccessoryType { none, cloud, disclosureIndicator } diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item.dart new file mode 100644 index 0000000..b116b1b --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:uuid/uuid.dart'; + +import '../../controllers/carplay_controller.dart'; +import '../common/image_tint.dart'; +import 'list_image_row_item/list_image_row_item_element.dart'; +import 'list_template_item.dart'; + +/// A List template row that displays a series of images. +/// https://developer.apple.com/documentation/carplay/cplistimagerowitem +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPListImageRowItem extends CPListTemplateItem { + /// Unique id of the object. + final String _elementId; + + /// The images that appear in the list item’s image row. + /// + /// Each entry accepts an asset path, an SVG Flutter asset (`.svg`, rasterized + /// to PNG before reaching the native side), a `file://` path, or a network + /// URL. Remote/`file://` SVGs are not supported. + /// iOS 14.0–26.0 | iPadOS 14.0–26.0 | Mac Catalyst 14.0–26.0 + final List? gridImages; + + /// Optional tints for [gridImages], aligned by index. + final List? gridImageTints; + + /// The titles displayed for each image in this image row item. + /// iOS 14.0–26.0 | iPadOS 14.0–26.0 | Mac Catalyst 14.0–26.0' + final List? imageTitles; + + /// The array of elements used to draw visible elements. + /// Can be one of the following types of elements: + /// - [CPListImageRowItemCardElement] + /// - [CPListImageRowItemCondensedElement] + /// - [CPListImageRowItemRowElement] + /// - [CPListImageRowItemGridElement] + /// - [CPListImageRowItemImageGridElement] + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + List? elements; + + /// A Boolean value indicating whether the elements should be visible in more than a single line. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + final bool allowsMultipleLines; + + /// An optional closure that CarPlay invokes when the user selects the list item. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + FutureOr Function(Function() complete, CPListImageRowItem self)? + onPress; + + /// An optional closure that CarPlay invokes when the user selects an image. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + FutureOr Function( + Function() complete, CPListImageRowItem self, int? index)? onItemPress; + + /// Creates [CPListImageRowItem] + CPListImageRowItem({ + super.text, + this.gridImages, + this.gridImageTints, + this.imageTitles, + this.elements, + this.allowsMultipleLines = false, + this.onPress, + this.onItemPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'text': text, + 'gridImages': gridImages, + 'gridImageTints': + gridImageTints?.map((tint) => tint?.toJson()).toList(), + 'imageTitles': imageTitles, + 'elements': elements?.map((e) => e.toJson()).toList(), + 'allowsMultipleLines': allowsMultipleLines, + 'onPress': onPress != null ? true : false, + 'onItemPress': onItemPress != null ? true : false, + 'runtimeType': 'FCPListImageRowItem', + }; + + void setText(String text) { + this.text = text; + FlutterCarPlayController.updateCPListImageRowItem(this); + } + + void setElements(List elements) { + this.elements = elements; + FlutterCarPlayController.updateCPListImageRowItem(this); + } + + /// The maximum number of images that an image row can contain. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 14.0+ + static Future getMaximumNumberOfGridImages() => + FlutterCarPlayController.getMaximumNumberOfGridImages(); + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/all.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/all.dart new file mode 100644 index 0000000..544767a --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/all.dart @@ -0,0 +1,7 @@ +export 'list_image_row_item_card_element.dart'; +export 'list_image_row_item_condensed_element.dart'; +export 'list_image_row_item_constants.dart'; +export 'list_image_row_item_element.dart'; +export 'list_image_row_item_grid_element.dart'; +export 'list_image_row_item_image_grid_element.dart'; +export 'list_image_row_item_row_element.dart'; diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_card_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_card_element.dart new file mode 100644 index 0000000..7d35439 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_card_element.dart @@ -0,0 +1,114 @@ +import 'package:uuid/uuid.dart'; + +import '../../../controllers/carplay_controller.dart'; +import '../../common/image_tint.dart'; +import '../../common/ui_color.dart'; +import 'list_image_row_item_element.dart'; + +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemcardelement +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +class CPListImageRowItemCardElement implements CPListImageRowItemElement { + /// Unique id of the object. + final String _elementId; + + /// The image to display in the card. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + @override + String image; + + @override + AutoImageTint? imageTint; + + /// The title associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? title; + + /// The subtitle associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? subtitle; + + /// A UIColor used to tint the element. When @c showsImageFullHeight is true, + /// the tint color is applied behind the labels at the bottom of the card. + /// Otherwise, this color is part of the gradient color at the bottom of the card. + UIColor? tintColor; + + /// A Boolean value indicating whether the element should be fill with the image. + /// iOS 14.0–26.0 | iPadOS 14.0–26.0 | Mac Catalyst 14.0–26.0 + final bool showsImageFullHeight; + + /// Creates [CPListImageRowItemCardElement] + CPListImageRowItemCardElement({ + required this.image, + this.imageTint, + this.title, + this.subtitle, + this.showsImageFullHeight = true, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'title': title, + 'subtitle': subtitle, + 'tintColor': tintColor?.toJson(), + 'showsImageFullHeight': showsImageFullHeight, + 'runtimeType': 'FCPListImageRowItemCardElement', + }; + + @override + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setTitle(String title) { + this.title = title; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setSubtitle(String subtitle) { + this.subtitle = subtitle; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setTintColor(UIColor tintColor) { + this.tintColor = tintColor; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void update({ + String? image, + AutoImageTint? imageTint, + String? title, + String? subtitle, + UIColor? tintColor, + bool? showsImageFullHeight, + }) { + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + if (title != null) this.title = title; + if (subtitle != null) this.subtitle = subtitle; + if (tintColor != null) this.tintColor = tintColor; + + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_condensed_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_condensed_element.dart new file mode 100644 index 0000000..b206a74 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_condensed_element.dart @@ -0,0 +1,115 @@ +import 'package:uuid/uuid.dart'; + +import '../../../controllers/carplay_controller.dart'; +import '../../common/image_tint.dart'; +import 'list_image_row_item_constants.dart'; +import 'list_image_row_item_element.dart'; + +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemcondensedelement +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +class CPListImageRowItemCondensedElement implements CPListImageRowItemElement { + /// Unique id of the object. + final String _elementId; + + /// The image to display in the card. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + @override + String image; + + @override + AutoImageTint? imageTint; + + /// The title associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String title; + + /// The subtitle associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? subtitle; + + /// The name of the system symbol image to use as accessory. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? accessorySymbolName; + + /// Shape used to draw the image of the element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + final CPListImageRowItemCondensedElementShape imageShape; + + /// Creates [CPListImageRowItemCondensedElement] + CPListImageRowItemCondensedElement({ + required this.image, + required this.title, + this.imageTint, + this.subtitle, + this.accessorySymbolName, + this.imageShape = CPListImageRowItemCondensedElementShape.roundedRectangle, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'title': title, + 'subtitle': subtitle, + 'accessorySymbolName': accessorySymbolName, + 'imageShape': imageShape.name, + 'runtimeType': 'FCPListImageRowItemCondensedElement', + }; + + @override + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setTitle(String title) { + this.title = title; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setSubtitle(String subtitle) { + this.subtitle = subtitle; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setAccessorySymbolName(String accessorySymbolName) { + this.accessorySymbolName = accessorySymbolName; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void update({ + String? image, + AutoImageTint? imageTint, + String? title, + String? subtitle, + String? accessorySymbolName, + }) { + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + if (title != null) this.title = title; + if (subtitle != null) this.subtitle = subtitle; + if (accessorySymbolName != null) { + this.accessorySymbolName = accessorySymbolName; + } + + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_constants.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_constants.dart new file mode 100644 index 0000000..28b8001 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_constants.dart @@ -0,0 +1,19 @@ +/// Types of shape used to draw a condensed row element. +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 14.0+ +enum CPListImageRowItemCondensedElementShape { + /// The list item will render an element with a circular image. + circular, + + /// The list item will render an element with a rounded rectangle image. + roundedRectangle, +} + +/// Types of shape used to draw a condensed row element. +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 14.0+ +enum CPListImageRowItemImageGridElementShape { + /// The list item will render an element with a circular image. + circular, + + /// The list item will render an element with a rounded rectangle image. + roundedRectangle, +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_element.dart new file mode 100644 index 0000000..9dd6f03 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_element.dart @@ -0,0 +1,28 @@ +import '../../common/image_tint.dart'; + +/// Abstract superclass for a a row item element object. +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemelement +abstract interface class CPListImageRowItemElement { + /// The image associated with this element. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? get image; + + /// Optional tint applied to [image]. + AutoImageTint? get imageTint; + + Map toJson(); + + String get uniqueId; + + /// Updates the element's image. See [image] for supported formats (including + /// `.svg` Flutter assets). + void setImage(String image, {AutoImageTint? imageTint}); + + /// Updates the tint applied to [image]. Pass `null` to remove the tint. + void setImageTint(AutoImageTint? imageTint); +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_grid_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_grid_element.dart new file mode 100644 index 0000000..76701c9 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_grid_element.dart @@ -0,0 +1,67 @@ +import 'package:uuid/uuid.dart'; + +import '../../../controllers/carplay_controller.dart'; +import '../../common/image_tint.dart'; +import 'list_image_row_item_element.dart'; + +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemgridelement +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +class CPListImageRowItemGridElement implements CPListImageRowItemElement { + /// Unique id of the object. + final String _elementId; + + /// The image to display in the card. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + @override + String image; + + @override + AutoImageTint? imageTint; + + /// Creates [CPListImageRowItemGridElement] + CPListImageRowItemGridElement({ + required this.image, + this.imageTint, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'runtimeType': 'FCPListImageRowItemGridElement', + }; + + @override + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void update({ + String? image, + AutoImageTint? imageTint, + }) { + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_image_grid_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_image_grid_element.dart new file mode 100644 index 0000000..c68754e --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_image_grid_element.dart @@ -0,0 +1,102 @@ +import 'package:uuid/uuid.dart'; + +import '../../../controllers/carplay_controller.dart'; +import '../../common/image_tint.dart'; +import 'list_image_row_item_constants.dart'; +import 'list_image_row_item_element.dart'; + +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemimagegridelement +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +class CPListImageRowItemImageGridElement implements CPListImageRowItemElement { + /// Unique id of the object. + final String _elementId; + + /// The image to display in the card. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + @override + String image; + + @override + AutoImageTint? imageTint; + + /// The title associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String title; + + /// The name of the system symbol image to use as accessory. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? accessorySymbolName; + + /// Shape used to draw the image of the element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + final CPListImageRowItemImageGridElementShape imageShape; + + /// Creates [CPListImageRowItemImageGridElement] + CPListImageRowItemImageGridElement({ + required this.image, + required this.title, + this.imageTint, + this.accessorySymbolName, + this.imageShape = CPListImageRowItemImageGridElementShape.circular, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'title': title, + 'accessorySymbolName': accessorySymbolName, + 'imageShape': imageShape.name, + 'runtimeType': 'FCPListImageRowItemImageGridElement', + }; + + @override + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setTitle(String title) { + this.title = title; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setAccessorySymbolName(String accessorySymbolName) { + this.accessorySymbolName = accessorySymbolName; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void update({ + String? image, + AutoImageTint? imageTint, + String? title, + String? accessorySymbolName, + }) { + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + if (title != null) this.title = title; + if (accessorySymbolName != null) { + this.accessorySymbolName = accessorySymbolName; + } + + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_row_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_row_element.dart new file mode 100644 index 0000000..e1ee61c --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_row_element.dart @@ -0,0 +1,93 @@ +import 'package:uuid/uuid.dart'; + +import '../../../controllers/carplay_controller.dart'; +import '../../common/image_tint.dart'; +import 'list_image_row_item_element.dart'; + +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemrowelement +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +class CPListImageRowItemRowElement implements CPListImageRowItemElement { + /// Unique id of the object. + final String _elementId; + + /// The image to display in the card. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + @override + String image; + + @override + AutoImageTint? imageTint; + + /// The title associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? title; + + /// The subtitle associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? subtitle; + + /// Creates [CPListImageRowItemRowElement] + CPListImageRowItemRowElement({ + required this.image, + this.imageTint, + this.title, + this.subtitle, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'title': title, + 'subtitle': subtitle, + 'runtimeType': 'FCPListImageRowItemRowElement', + }; + + @override + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setTitle(String title) { + this.title = title; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setSubtitle(String subtitle) { + this.subtitle = subtitle; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void update({ + String? image, + AutoImageTint? imageTint, + String? title, + String? subtitle, + }) { + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + if (title != null) this.title = title; + if (subtitle != null) this.subtitle = subtitle; + + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_item.dart b/third_party/flutter_carplay/lib/models/list/list_item.dart new file mode 100644 index 0000000..a2b3ef0 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_item.dart @@ -0,0 +1,235 @@ +import 'dart:async'; + +import 'package:flutter_carplay/controllers/carplay_controller.dart'; +import 'package:flutter_carplay/models/common/image_tint.dart'; +import 'package:flutter_carplay/models/list/list_constants.dart'; +import 'package:uuid/uuid.dart'; + +import 'list_template_item.dart'; + +/// A selectable row in a list template. +/// https://developer.apple.com/documentation/carplay/cplistitem +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPListItem extends CPListTemplateItem { + /// Unique id of the object. + final String _elementId; + + /// The list item’s secondary text. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + String? detailText; + + /// The image that the list item displays in its leading region. + /// + /// Supports these formats: + /// * Asset path: `images/flutter_logo.png` from pubspec.yaml assets + /// * SVG asset: `images/icon.svg` rasterized to PNG before native display + /// * File path: `file:///path/to/image.png` local file on device + /// * Network URL: `https://example.com/image.png` remote image + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + String? image; + + /// Optional tint applied to [image]. Use [AutoImageTint.platform] when the + /// host should choose a selected or focused row safe color. + AutoImageTint? imageTint; + + /// Backward compatible trailing accessory image. + /// + /// New code should prefer [trailingImage], which also supports SVG assets and + /// tint metadata. + String? accessoryImage; + + /// The image that the list item displays in its trailing region. + /// + /// This maps to CarPlay's `accessoryImage` and takes precedence over + /// [accessoryImage] and [accessoryType]. Use it for state indicators while + /// keeping [image] for the leading user selected icon. + String? trailingImage; + + /// Optional tint applied to [trailingImage]. + AutoImageTint? trailingImageTint; + + /// The playback progress status for the content that the list item represents. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + double? playbackProgress; + + /// A Boolean value that determines whether the list item displays its Now Playing indicator. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + bool? isPlaying; + + /// The location where the list item displays its Now Playing indicator. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + CPListItemPlayingIndicatorLocation? playingIndicatorLocation; + + /// The accessory that the list item displays in its trailing region. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + CPListItemAccessoryType? accessoryType; + + /// An optional closure that CarPlay invokes when the user selects the list item. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final FutureOr Function(Function() complete, CPListItem self)? onPress; + + /// Creates [CPListItem] that manages the content of a single row in a [CPListTemplate]. + /// CarPlay manages the layout of a list item and may adjust its layout to allow for + /// the display of auxiliary content, such as, an accessory or a Now Playing indicator. + /// A list item can display primary text, secondary text, now playing indicators as playback progress, + /// an accessory image and a trailing image. + CPListItem({ + super.text, + this.detailText, + this.onPress, + this.image, + this.imageTint, + this.accessoryImage, + this.trailingImage, + this.trailingImageTint, + this.playbackProgress, + this.isPlaying, + this.playingIndicatorLocation, + this.accessoryType, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'text': text, + 'detailText': detailText, + 'onPress': onPress != null ? true : false, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'accessoryImage': accessoryImage, + 'trailingImage': trailingImage, + 'trailingImageTint': trailingImageTint?.toJson(), + 'playbackProgress': playbackProgress, + 'isPlaying': isPlaying, + 'playingIndicatorLocation': playingIndicatorLocation?.name, + 'accessoryType': accessoryType?.name, + 'runtimeType': 'FCPListItem', + }; + + /// Updating the list item's primary text. + void setText(String text) { + this.text = text; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updating the list item's secondary text. + void setDetailText(String detailText) { + this.detailText = detailText; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updating the image which will be displayed on the leading edge of the list item cell. + /// + /// Supports these formats: + /// * Asset path: `images/flutter_logo.png` from pubspec.yaml assets + /// * SVG asset: `images/icon.svg` rasterized to PNG before native display + /// * File path: `file:///path/to/image.png` local file on device + /// * Network URL: `https://example.com/image.png` remote image + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updates the tint applied to [image]. Pass `null` to remove the tint. + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updating the image displayed on the trailing edge of the list item cell. + /// + /// See [trailingImage] for supported formats, including SVG Flutter assets. + void setTrailingImage(String trailingImage, {AutoImageTint? imageTint}) { + this.trailingImage = trailingImage; + if (imageTint != null) trailingImageTint = imageTint; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updates the tint applied to [trailingImage]. Pass `null` to remove it. + void setTrailingImageTint(AutoImageTint? imageTint) { + trailingImageTint = imageTint; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updating the image displayed in the trailing region of the list item cell. + /// + /// Supports the same asset path, file path, and network URL formats as [image]. + void setAccessoryImage(String? accessoryImage) { + this.accessoryImage = accessoryImage; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Setter for playbackProgress + /// When the given value is not between 0.0 and 1.0, throws [RangeError] + void setPlaybackProgress(double playbackProgress) { + if (playbackProgress >= 0.0 && playbackProgress <= 1.0) { + this.playbackProgress = playbackProgress; + FlutterCarPlayController.updateCPListItem(this); + } else { + throw RangeError('playbackProgress must be between 0.0 and 1.0'); + } + } + + /// Setter for isPlaying + void setIsPlaying(bool isPlaying) { + this.isPlaying = isPlaying; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Setter for playingIndicatorLocation + void setPlayingIndicatorLocation( + CPListItemPlayingIndicatorLocation playingIndicatorLocation, + ) { + this.playingIndicatorLocation = playingIndicatorLocation; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Setter for accessoryType + void setAccessoryType(CPListItemAccessoryType accessoryType) { + this.accessoryType = accessoryType; + FlutterCarPlayController.updateCPListItem(this); + } + + void update({ + String? text, + String? detailText, + String? image, + AutoImageTint? imageTint, + String? accessoryImage, + String? trailingImage, + AutoImageTint? trailingImageTint, + double? playbackProgress, + bool? isPlaying, + CPListItemPlayingIndicatorLocation? playingIndicatorLocation, + CPListItemAccessoryType? accessoryType, + }) { + if (text != null) this.text = text; + if (detailText != null) this.detailText = detailText; + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + if (accessoryImage != null) this.accessoryImage = accessoryImage; + if (trailingImage != null) this.trailingImage = trailingImage; + if (trailingImageTint != null) this.trailingImageTint = trailingImageTint; + if (playbackProgress != null) { + if (playbackProgress >= 0.0 && playbackProgress <= 1.0) { + this.playbackProgress = playbackProgress; + } else { + throw RangeError('playbackProgress must be between 0.0 and 1.0'); + } + } + if (isPlaying != null) this.isPlaying = isPlaying; + if (playingIndicatorLocation != null) { + this.playingIndicatorLocation = playingIndicatorLocation; + } + if (accessoryType != null) this.accessoryType = accessoryType; + + FlutterCarPlayController.updateCPListItem(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_section.dart b/third_party/flutter_carplay/lib/models/list/list_section.dart new file mode 100644 index 0000000..975ea97 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_section.dart @@ -0,0 +1,46 @@ +import 'package:uuid/uuid.dart'; + +import 'list_template_item.dart'; + +/// A container that groups your list items into sections. +/// https://developer.apple.com/documentation/carplay/cplistsection +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPListSection { + /// Unique id of the object. + final String _elementId; + + /// The section’s header text. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String? header; + + /// The section’s index title. + /// Defaults to true. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final bool? sectionIndexEnabled; + + /// The list of items for the section. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final List items; + + /// Creates [CPListSection] that contains zero or more list items. You can configure + /// a section to display a header, which CarPlay displays on the trailing edge of the screen. + CPListSection({ + this.header, + this.sectionIndexEnabled, + required List items, + String? id, + }) : items = List.from(items), + _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'header': header, + 'items': items.map((e) => e.toJson()).toList(), + 'sectionIndexEnabled': sectionIndexEnabled, + 'runtimeType': 'FCPListSection', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_template.dart b/third_party/flutter_carplay/lib/models/list/list_template.dart new file mode 100644 index 0000000..7bd9671 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_template.dart @@ -0,0 +1,89 @@ +import 'package:flutter_carplay/models/button/bar_button.dart'; +import 'package:flutter_carplay/models/list/list_section.dart'; +import 'package:uuid/uuid.dart'; + +import '../../controllers/carplay_controller.dart'; +import '../template.dart'; + +/// A template that displays and manages a list of items. +/// https://developer.apple.com/documentation/carplay/CPListTemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPListTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// The title that the navigation bar displays when the template is visible + /// Notes: + /// - [systemIcon] must be set in order for the title to be displayed in a tab bar. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String? title; + + /// The sections that the list displays. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final List sections; + + /// An array of title variants for the template’s empty view. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List? emptyViewTitleVariants; + + /// An array of subtitle variants for the template’s empty view. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List? emptyViewSubtitleVariants; + + /// A button to display as the Back button on the navigation bar. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final CPBarButton? backButton; + + /// Creates [CPListTemplate] to display a list of items, grouped into one or more sections. + /// Each section contains an array of list items — objects that is [CPListItem] + /// + /// **Consider that some vehicles limit the number of items that [CPListTemplate] displays.** + CPListTemplate({ + this.title, + required this.sections, + this.emptyViewTitleVariants, + this.emptyViewSubtitleVariants, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + super.onPop, + this.backButton, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'sections': sections.map((e) => e.toJson()).toList(), + 'emptyViewTitleVariants': emptyViewTitleVariants, + 'emptyViewSubtitleVariants': emptyViewSubtitleVariants, + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'backButton': backButton?.toJson(), + 'runtimeType': 'FCPListTemplate', + }; + + /// The maximum number of sections that the template can display. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + static Future getMaximumSectionCount() => + FlutterCarPlayController.getMaximumSectionCount(); + + /// The maximum number of items, across all sections, that the template can display. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + static Future getMaximumItemCount() => + FlutterCarPlayController.getMaximumItemCount(); + + @override + String get uniqueId { + return _elementId; + } + + void updateSections(List newSections) { + final copy = List.from(newSections); + sections + ..clear() + ..addAll(copy); + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_template_item.dart b/third_party/flutter_carplay/lib/models/list/list_template_item.dart new file mode 100644 index 0000000..ef0cd47 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_template_item.dart @@ -0,0 +1,16 @@ +/// A description of the common properties of all list item types. +/// https://developer.apple.com/documentation/carplay/cplisttemplateitem +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +abstract class CPListTemplateItem { + CPListTemplateItem({ + this.text, + }); + + /// The item’s primary text. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? text; + + Map toJson(); + + String get uniqueId; +} diff --git a/third_party/flutter_carplay/lib/models/poi/all.dart b/third_party/flutter_carplay/lib/models/poi/all.dart new file mode 100644 index 0000000..adf0fb6 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/poi/all.dart @@ -0,0 +1,2 @@ +export 'poi.dart'; +export 'poi_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/poi/poi.dart b/third_party/flutter_carplay/lib/models/poi/poi.dart new file mode 100644 index 0000000..83956c8 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/poi/poi.dart @@ -0,0 +1,102 @@ +import 'package:uuid/uuid.dart'; + +import '../common/image_tint.dart'; +import '../button/text_button.dart'; + +/// A section object of list items that appear in a list template. +/// https://developer.apple.com/documentation/carplay/cppointofinterest +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPPointOfInterest { + /// Unique id of the object. + final String _elementId; + + /// The latitude of the geographical coordinate. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + double latitude; + + /// The longitude of the geographical coordinate. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + double longitude; + + /// The title that the picker displays for the item. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String title; + + /// The subtitle that the picker displays for the item. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? subtitle; + + /// A brief summary that the picker displays for the item. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? summary; + + /// The detail card’s title. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? detailTitle; + + /// The detail card’s subtitle. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? detailSubtitle; + + /// A brief summary that the detail card displays. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? detailSummary; + + /// A custom image that the map annotation displays. + /// Supports these formats: + /// - **Asset path**: `images/marker.png` (from pubspec.yaml assets) + /// - **SVG asset**: `images/marker.svg` (rasterized to PNG before being sent + /// to the native side; remote/`file://` SVGs are not supported) + /// - **File path**: `file:///path/to/image.png` (local file on device) + /// - **Network URL**: `https://example.com/image.png` (remote image) + String? image; + + /// Optional tint applied to [image]. + AutoImageTint? imageTint; + + /// The detail card’s primary action button. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + CPTextButton? primaryButton; + + /// The detail card’s secondary action button. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + CPTextButton? secondaryButton; + + /// Creates [CPPointOfInterest] + CPPointOfInterest({ + required this.latitude, + required this.longitude, + required this.title, + this.subtitle, + this.summary, + this.detailTitle, + this.detailSubtitle, + this.detailSummary, + this.image, + this.imageTint, + this.primaryButton, + this.secondaryButton, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'latitude': latitude, + 'longitude': longitude, + 'title': title, + 'subtitle': subtitle, + 'summary': summary, + 'detailTitle': detailTitle, + 'detailSubtitle': detailSubtitle, + 'detailSummary': detailSummary, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'primaryButton': primaryButton?.toJson(), + 'secondaryButton': secondaryButton?.toJson(), + 'runtimeType': 'FCPPointOfInterest', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/poi/poi_template.dart b/third_party/flutter_carplay/lib/models/poi/poi_template.dart new file mode 100644 index 0000000..9cb6fce --- /dev/null +++ b/third_party/flutter_carplay/lib/models/poi/poi_template.dart @@ -0,0 +1,47 @@ +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'poi.dart'; + +/// A template that displays a map with selectable points of interest. +/// https://developer.apple.com/documentation/carplay/cppointofinteresttemplate +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPPointOfInterestTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// The scrollable picker’s title. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String title; + + /// The points of interest the template displays on the map and in the scrollable picker. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List poi; + + /// Creates [CPPointOfInterestTemplate] + CPPointOfInterestTemplate({ + required this.title, + required this.poi, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + super.onPop, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'poi': poi.map((e) => e.toJson()).toList(), + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPPointOfInterestTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/search/all.dart b/third_party/flutter_carplay/lib/models/search/all.dart new file mode 100644 index 0000000..7806666 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/search/all.dart @@ -0,0 +1 @@ +export 'search_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/search/search_template.dart b/third_party/flutter_carplay/lib/models/search/search_template.dart new file mode 100644 index 0000000..d85b0c9 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/search/search_template.dart @@ -0,0 +1,59 @@ +import 'package:uuid/uuid.dart'; + +import '../list/list_item.dart'; +import '../template.dart'; + +/// A template that provides the ability to search for a destination and see a list of search results. +/// https://developer.apple.com/documentation/carplay/cpsearchtemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPSearchTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// Tells the delegate that the user updated the search criteria text. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function(String searchText, Function(List results) update)? + onUpdatedSearchText; + + /// Tells the delegate that the user selected an item from the search result. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function(CPListItem selectedItem, Function() complete)? + onSelectedResult; + + /// Tells the delegate that the user tapped the keyboard's search button. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function()? onSearchTemplateSearchButtonPressed; + + final List _currentResults = []; + + /// Creates [CPSearchTemplate] to display a CarPlay search interface. + CPSearchTemplate({ + String? id, + this.onUpdatedSearchText, + this.onSelectedResult, + this.onSearchTemplateSearchButtonPressed, + }) : _elementId = id ?? const Uuid().v4(), + super(); + + @override + Map toJson() => { + 'runtimeType': 'FCPSearchTemplate', + '_elementId': _elementId, + 'onUpdatedSearchText': onUpdatedSearchText != null, + 'onSelectedResult': onSelectedResult != null, + 'onSearchTemplateSearchButtonPressed': + onSearchTemplateSearchButtonPressed != null, + }; + + @override + String get uniqueId => _elementId; + + List get currentResults => _currentResults; + + void updateResults(List results) { + final copy = List.from(results); + _currentResults + ..clear() + ..addAll(copy); + } +} diff --git a/third_party/flutter_carplay/lib/models/tabbar/all.dart b/third_party/flutter_carplay/lib/models/tabbar/all.dart new file mode 100644 index 0000000..b9ce2e6 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/tabbar/all.dart @@ -0,0 +1 @@ +export 'tabbar_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/tabbar/tabbar_template.dart b/third_party/flutter_carplay/lib/models/tabbar/tabbar_template.dart new file mode 100644 index 0000000..4a30898 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/tabbar/tabbar_template.dart @@ -0,0 +1,65 @@ +import 'package:flutter_carplay/models/action_sheet/action_sheet_template.dart'; +import 'package:flutter_carplay/models/alert/alert_template.dart'; +import 'package:flutter_carplay/models/grid/grid_template.dart'; +import 'package:flutter_carplay/models/information/information_template.dart'; +import 'package:flutter_carplay/models/list/list_template.dart'; +import 'package:flutter_carplay/models/poi/poi_template.dart'; +import 'package:uuid/uuid.dart'; + +import '../template.dart'; + +/// A container template that displays and manages other templates, presenting them as tabs. +/// Supported template types: [CPListTemplate], [CPPointOfInterestTemplate], +/// [CPGridTemplate], [CPInformationTemplate], [CPActionSheetTemplate], [CPAlertTemplate] +/// https://developer.apple.com/documentation/carplay/cptabbartemplate +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPTabBarTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// The tab bar’s templates. + /// Supported types: [CPListTemplate], [CPPointOfInterestTemplate], + /// [CPGridTemplate], [CPInformationTemplate] + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List templates; + + /// When creating a [CPTabBarTemplate], provide an array of templates for the tab bar to display. + /// CarPlay treats the array’s templates as root templates, each with its own + /// navigation hierarchy. When a tab bar template is the rootTemplate of your + /// app’s interface controller and you use the controller to add and remove templates, + /// CarPlay applies those changes to the selected tab’s navigation hierarchy. + /// + /// [!] You can’t add a tab bar template to an existing navigation hierarchy, + /// or present one modally. + CPTabBarTemplate({ + required List templates, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + super.onPop, + String? id, + }) : templates = List.from(templates), + _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'tabTitle': tabTitle, + 'templates': templates.map((e) => e.toJson()).toList(), + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPTabBarTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } + + void updateTemplates(List newTemplates) { + final copy = List.from(newTemplates); + templates + ..clear() + ..addAll(copy); + } +} diff --git a/third_party/flutter_carplay/lib/models/template.dart b/third_party/flutter_carplay/lib/models/template.dart new file mode 100644 index 0000000..9d7617f --- /dev/null +++ b/third_party/flutter_carplay/lib/models/template.dart @@ -0,0 +1,50 @@ +import 'package:flutter/foundation.dart'; + +import 'alert/alert_action.dart'; + +/// https://developer.apple.com/documentation/carplay/cptemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +abstract class CPTemplate { + CPTemplate({ + this.tabTitle, + this.showsTabBadge = false, + this.systemIcon, + this.onPop, + }); + + /// An indicator you use to call attention to the tab. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final bool showsTabBadge; + + /// An image that represents the content of the tab. + /// Note: + /// - This property is given to tabImage + /// - If null, template title will not be display in the tab bar. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String? systemIcon; + + /// A short title that describes the content of the tab. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String? tabTitle; + + /// Called when this template is popped from the navigation stack. + /// + /// Fires for both user-initiated pops (CarPlay back button) and + /// programmatic pops via [FlutterCarplay.pop]. Useful for cleaning + /// up subscriptions, state listeners, or analytics events tied to + /// this template's lifetime. + /// + /// Not called for modal templates (alerts, action sheets) — those + /// have their own lifecycle hooks. + final VoidCallback? onPop; + + String get uniqueId; + + Map toJson(); +} + +abstract interface class CPActionsTemplate { + const CPActionsTemplate(); + + List get actions; +} diff --git a/third_party/flutter_carplay/pubspec.yaml b/third_party/flutter_carplay/pubspec.yaml new file mode 100644 index 0000000..53b5192 --- /dev/null +++ b/third_party/flutter_carplay/pubspec.yaml @@ -0,0 +1,35 @@ +name: flutter_carplay +description: Flutter Apps are now on Apple CarPlay and Android Auto. This package aims to make it safe to use apps made with Flutter in the car by integrating with CarPlay or Android Auto. +version: 1.6.5 +homepage: https://github.com/oguzhnatly/flutter_carplay#readme +repository: https://github.com/oguzhnatly/flutter_carplay +issue_tracker: https://github.com/oguzhnatly/flutter_carplay/issues + +environment: + sdk: ">=3.6.0 <4.0.0" + flutter: ">=1.10.0" + +dependencies: + flutter: + sdk: flutter + flutter_svg: ^2.0.10+1 + uuid: ^4.5.3 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + plugin: + platforms: + # DELTA A from upstream 1.6.5: the `ios:` entry and the whole ios/ + # directory are removed. MeshMapper uses this package for Android Auto + # only. Left in place, it would link SwiftFlutterCarplayPlugin into the + # App Store build — a CarPlay scene-delegate and entitlement surface we + # do not want and cannot ship without Apple's CarPlay entitlement. + # The Dart AA* classes are pure Dart plus a MethodChannel and still + # compile on iOS, where AndroidAutoService.isSupportedPlatform is false. + android: + package: com.oguzhnatly.flutter_android_auto + pluginClass: FlutterAndroidAutoPlugin