From 13cdd2b199027f40eaf0b555940e6bb3fcc26c80 Mon Sep 17 00:00:00 2001 From: Julian Bissekkou Date: Thu, 2 Jul 2026 16:57:59 +0200 Subject: [PATCH 01/16] add new example for issue --- .../xcshareddata/swiftpm/Package.resolved | 14 ++ .../xcshareddata/swiftpm/Package.resolved | 14 ++ example/lib/examples/map_bottom_sheet.dart | 230 ++++++++++++++++++ .../lib/examples/route_and_icons_example.dart | 118 +++++++++ example/lib/main.dart | 182 +++++++------- 5 files changed, 461 insertions(+), 97 deletions(-) create mode 100644 example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 example/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 example/lib/examples/map_bottom_sheet.dart create mode 100644 example/lib/examples/route_and_icons_example.dart diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..adfe38d --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "maplibre-gl-native-distribution", + "kind" : "remoteSourceControl", + "location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git", + "state" : { + "revision" : "84a79bc375a301169390ac110c868f06c857b83f", + "version" : "6.27.0" + } + } + ], + "version" : 2 +} diff --git a/example/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/example/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..adfe38d --- /dev/null +++ b/example/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "maplibre-gl-native-distribution", + "kind" : "remoteSourceControl", + "location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git", + "state" : { + "revision" : "84a79bc375a301169390ac110c868f06c857b83f", + "version" : "6.27.0" + } + } + ], + "version" : 2 +} diff --git a/example/lib/examples/map_bottom_sheet.dart b/example/lib/examples/map_bottom_sheet.dart new file mode 100644 index 0000000..0797e67 --- /dev/null +++ b/example/lib/examples/map_bottom_sheet.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; +import 'package:route_map/route_map.dart'; + +/// A small map plus a button that opens a draggable bottom sheet containing a +/// more detailed map. Both maps render the same (fairly complex) no-service +/// area so the layer setup runs on every style load. +/// +/// Dragging / resizing the sheet embeds the map in an animated container and +/// the refresh button reloads the style at runtime — both re-fire +/// `onStyleLoadedCallback`, which is what surfaces issue #13 +/// (`sourceAlreadyExists` for `no_service_area_source_id_0`). +const _lightStyle = + "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; +const _darkStyle = + "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"; + +/// A more complex service area: a multi-polygon (two separate areas) where the +/// first polygon also has a hole. +const Map _serviceAreaGeoJson = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + // Polygon 1 — irregular outer ring ... + [ + [11.35, 48.00], + [11.50, 47.97], + [11.66, 48.02], + [11.74, 48.12], + [11.70, 48.24], + [11.58, 48.30], + [11.45, 48.27], + [11.36, 48.18], + [11.30, 48.09], + [11.35, 48.00], + ], + // ... with a hole in the middle. + [ + [11.50, 48.10], + [11.60, 48.10], + [11.62, 48.17], + [11.53, 48.19], + [11.48, 48.15], + [11.50, 48.10], + ], + ], + [ + // Polygon 2 — a separate area to the east. + [ + [11.82, 48.02], + [11.95, 48.05], + [11.98, 48.14], + [11.90, 48.20], + [11.80, 48.13], + [11.82, 48.02], + ], + ], + ], + }, + }, + ], +}; + +Future _createServiceAreaSource() async { + return const GeojsonSourceProperties(data: _serviceAreaGeoJson); +} + +List _noServiceAreaLayers() => [ + NoServiceAreaLayer( + createSource: _createServiceAreaSource, + fillColor: Colors.grey.withValues(alpha: 0.45), + hashLines: const NoServiceAreaHashLines( + color: Colors.black26, + spacing: 10, + ), + border: const NoServiceAreaBorder( + createSource: _createServiceAreaSource, + color: Colors.redAccent, + width: 2, + ), + ), +]; + +class MapBottomSheetPage extends StatefulWidget { + const MapBottomSheetPage({super.key}); + + @override + State createState() => _MapBottomSheetPageState(); +} + +class _MapBottomSheetPageState extends State { + final _smallMapController = RouteMapController(); + + Future _openSheet() { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => const _DetailedMapSheet(), + ); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + height: 240, + child: RouteMap( + styleUrl: _lightStyle, + locale: "en", + zoomPadding: const EdgeInsets.all(24), + controller: _smallMapController, + initialCameraPosition: const CameraPosition( + target: LatLng(48.13, 11.62), + zoom: 7.5, + ), + noServiceAreaLayers: _noServiceAreaLayers(), + onMapClicked: (_, _) {}, + ), + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _openSheet, + icon: const Icon(Icons.open_in_full), + label: const Text("Open map"), + ), + ], + ), + ); + } +} + +/// Detailed map shown inside the modal sheet. The sheet is resizable by +/// dragging the handle, and the refresh button toggles the style at runtime. +class _DetailedMapSheet extends StatefulWidget { + const _DetailedMapSheet(); + + @override + State<_DetailedMapSheet> createState() => _DetailedMapSheetState(); +} + +class _DetailedMapSheetState extends State<_DetailedMapSheet> { + final _controller = RouteMapController(); + + double _heightFactor = 0.6; + String _styleUrl = _lightStyle; + + void _toggleStyle() { + setState(() { + _styleUrl = _styleUrl == _lightStyle ? _darkStyle : _lightStyle; + }); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final maxHeight = constraints.maxHeight; + return FractionallySizedBox( + alignment: Alignment.bottomCenter, + heightFactor: _heightFactor, + child: Column( + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: (details) { + setState(() { + _heightFactor = + (_heightFactor - details.primaryDelta! / maxHeight) + .clamp(0.3, 1.0); + }); + }, + child: SizedBox( + height: 44, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(2), + ), + ), + Positioned( + right: 4, + child: IconButton( + tooltip: "Reload style", + onPressed: _toggleStyle, + icon: const Icon(Icons.refresh), + ), + ), + ], + ), + ), + ), + Expanded( + child: RouteMap( + styleUrl: _styleUrl, + locale: "en", + zoomPadding: const EdgeInsets.all(40), + controller: _controller, + initialCameraPosition: const CameraPosition( + target: LatLng(48.13, 11.62), + zoom: 8.5, + ), + noServiceAreaLayers: _noServiceAreaLayers(), + onMapClicked: (_, _) {}, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/example/lib/examples/route_and_icons_example.dart b/example/lib/examples/route_and_icons_example.dart new file mode 100644 index 0000000..508e14b --- /dev/null +++ b/example/lib/examples/route_and_icons_example.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:route_map/route_map.dart'; + +/// Original demo: draws a route with a start and end pin. +class RouteAndIconsExample extends StatefulWidget { + const RouteAndIconsExample({super.key}); + + @override + State createState() => _RouteAndIconsExampleState(); +} + +class _RouteAndIconsExampleState extends State { + final _mapController = RouteMapController(); + + Path _getPinPath() { + return Path()..addOval(const Rect.fromLTWH(0, 0, 36, 36)); + } + + Future drawRoute() async { + if (!mounted) return; + + await _mapController.removeRoutes(); + await _mapController.removeIcons(); + + const start = LatLng(47.7826, 9.6106); + const destination = LatLng(48.1371, 11.5754); + + await _mapController.drawIcon( + RouteMapIcon( + identifier: "start_pin", + latLng: start, + markerPath: _getPinPath(), + text: "A", + theme: const RouteMapIconTheme( + background: Colors.green, + foreground: Colors.white, + strokeWidth: 2, + padding: 4, + drawCircleAroundIcon: true, + ), + ), + ); + + if (!mounted) return; + + await _mapController.drawIcon( + RouteMapIcon( + identifier: "end_pin", + latLng: destination, + markerPath: _getPinPath(), + text: "B", + theme: const RouteMapIconTheme( + background: Colors.red, + foreground: Colors.white, + strokeWidth: 2, + padding: 4, + drawCircleAroundIcon: true, + ), + ), + ); + + if (!mounted) return; + await _mapController.drawRoute( + route: const RouteMapRoute( + identifier: "route-identifier", + points: [start, destination], + theme: RouteMapRouteTheme( + lineWidth: 5, + color: Color.fromARGB(255, 85, 97, 117), + backLineColor: Colors.black45, + backLineWidth: 7, + ), + ), + animateCamera: true, + ); + } + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + final styleUrl = isDarkMode + ? "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json" + : "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; + + return Stack( + children: [ + RouteMap( + minMaxZoomPreference: const MinMaxZoomPreference(5, 18), + styleUrl: styleUrl, + locale: "en", + zoomPadding: const EdgeInsets.only( + left: 40, + right: 40, + top: 60, + bottom: 100, + ), + allowIconsOverlap: true, + ignoreIconsPlacement: true, + controller: _mapController, + initialCameraPosition: const CameraPosition( + target: LatLng(48.123287, 11.572062), + zoom: 15, + ), + onMapClicked: (_, _) {}, + ), + Positioned( + right: 16, + bottom: 16, + child: FloatingActionButton( + onPressed: drawRoute, + child: const Icon(Icons.route), + ), + ), + ], + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 4d11057..0bb4da4 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:route_map/route_map.dart'; + +import 'package:example/examples/map_bottom_sheet.dart'; +import 'package:example/examples/route_and_icons_example.dart'; void main() { runApp(const MyApp()); @@ -25,118 +27,104 @@ class MyApp extends StatelessWidget { ), ), themeMode: ThemeMode.system, - home: const MyHomePage(), + home: const HomeShell(), ); } } -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key}); - - @override - State createState() => _MyHomePageState(); +/// A single selectable example in the drawer. +class ExamplePage { + final String title; + final String subtitle; + final IconData icon; + final WidgetBuilder builder; + + const ExamplePage({ + required this.title, + required this.subtitle, + required this.icon, + required this.builder, + }); } -class _MyHomePageState extends State { - final _mapController = RouteMapController(); - - Path _getPinPath() { - return Path()..addOval(const Rect.fromLTWH(0, 0, 36, 36)); - } - - Future drawRoute() async { - if (!mounted) return; - - await _mapController.removeRoutes(); - await _mapController.removeIcons(); - - const start = LatLng(47.7826, 9.6106); - const destination = LatLng(48.1371, 11.5754); - - await _mapController.drawIcon( - RouteMapIcon( - identifier: "start_pin", - latLng: start, - markerPath: _getPinPath(), - text: "A", - theme: const RouteMapIconTheme( - background: Colors.green, - foreground: Colors.white, - strokeWidth: 2, - padding: 4, - drawCircleAroundIcon: true, - ), - ), - ); - - if (!mounted) return; +final _examples = [ + ExamplePage( + title: "Route & Icons", + subtitle: "Draw a route with start/end pins", + icon: Icons.route, + builder: (_) => const RouteAndIconsExample(), + ), + ExamplePage( + title: "Map bottom sheet", + subtitle: "Detailed map in a draggable sheet", + icon: Icons.layers, + builder: (_) => const MapBottomSheetPage(), + ), +]; + +/// App shell hosting the navigation drawer and the currently-selected example. +class HomeShell extends StatefulWidget { + const HomeShell({super.key}); - await _mapController.drawIcon( - RouteMapIcon( - identifier: "end_pin", - latLng: destination, - markerPath: _getPinPath(), - text: "B", - theme: const RouteMapIconTheme( - background: Colors.red, - foreground: Colors.white, - strokeWidth: 2, - padding: 4, - drawCircleAroundIcon: true, - ), - ), - ); + @override + State createState() => _HomeShellState(); +} - if (!mounted) return; - await _mapController.drawRoute( - route: const RouteMapRoute( - identifier: "route-identifier", - points: [start, destination], - theme: RouteMapRouteTheme( - lineWidth: 5, - color: Color.fromARGB(255, 85, 97, 117), - backLineColor: Colors.black45, - backLineWidth: 7, - ), - ), - animateCamera: true, - ); +class _HomeShellState extends State { + int _selectedIndex = 0; - if (!mounted) return; + void _select(int index) { + setState(() => _selectedIndex = index); + Navigator.of(context).pop(); // close the drawer } @override Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - final styleUrl = isDarkMode - ? "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json" - : "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; + final example = _examples[_selectedIndex]; return Scaffold( - appBar: AppBar(title: const Text("Map")), - floatingActionButton: FloatingActionButton( - onPressed: drawRoute, - child: const Icon(Icons.route), - ), - body: RouteMap( - minMaxZoomPreference: const MinMaxZoomPreference(5, 18), - styleUrl: styleUrl, - locale: "en", - zoomPadding: const EdgeInsets.only( - left: 40, - right: 40, - top: 60, - bottom: 100, - ), - allowIconsOverlap: true, - ignoreIconsPlacement: true, - controller: _mapController, - initialCameraPosition: const CameraPosition( - target: LatLng(48.123287, 11.572062), - zoom: 15, + appBar: AppBar(title: Text(example.title)), + drawer: Drawer( + child: SafeArea( + child: Column( + children: [ + const DrawerHeader( + child: Align( + alignment: Alignment.bottomLeft, + child: Text( + "route_map examples", + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + Expanded( + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: _examples.length, + itemBuilder: (context, index) { + final e = _examples[index]; + return ListTile( + leading: Icon(e.icon), + title: Text(e.title), + subtitle: Text(e.subtitle), + selected: index == _selectedIndex, + onTap: () => _select(index), + ); + }, + ), + ), + ], + ), ), - onMapClicked: (_, location) async {}, + ), + // A key per index ensures each example is fully rebuilt (fresh map + // controllers) when switching pages. + body: KeyedSubtree( + key: ValueKey(_selectedIndex), + child: example.builder(context), ), ); } From 00e9f3225bb08ce78c3d8fdc079ed830f2041aaf Mon Sep 17 00:00:00 2001 From: Julian Bissekkou Date: Thu, 2 Jul 2026 17:30:38 +0200 Subject: [PATCH 02/16] cleanup example --- example/lib/examples/map_bottom_sheet.dart | 34 ++++++------------- .../lib/examples/route_and_icons_example.dart | 3 ++ lib/src/route_map_base.dart | 4 +-- 3 files changed, 16 insertions(+), 25 deletions(-) diff --git a/example/lib/examples/map_bottom_sheet.dart b/example/lib/examples/map_bottom_sheet.dart index 0797e67..ed9a30e 100644 --- a/example/lib/examples/map_bottom_sheet.dart +++ b/example/lib/examples/map_bottom_sheet.dart @@ -16,7 +16,7 @@ const _darkStyle = /// A more complex service area: a multi-polygon (two separate areas) where the /// first polygon also has a hole. -const Map _serviceAreaGeoJson = { +const Map serviceAreaGeoJson = { "type": "FeatureCollection", "features": [ { @@ -66,20 +66,20 @@ const Map _serviceAreaGeoJson = { ], }; -Future _createServiceAreaSource() async { - return const GeojsonSourceProperties(data: _serviceAreaGeoJson); +Future createServiceAreaSource() async { + return const GeojsonSourceProperties(data: serviceAreaGeoJson); } -List _noServiceAreaLayers() => [ +List noServiceAreaLayers() => [ NoServiceAreaLayer( - createSource: _createServiceAreaSource, + createSource: createServiceAreaSource, fillColor: Colors.grey.withValues(alpha: 0.45), hashLines: const NoServiceAreaHashLines( color: Colors.black26, spacing: 10, ), border: const NoServiceAreaBorder( - createSource: _createServiceAreaSource, + createSource: createServiceAreaSource, color: Colors.redAccent, width: 2, ), @@ -117,6 +117,7 @@ class _MapBottomSheetPageState extends State { child: SizedBox( height: 240, child: RouteMap( + key: const Key("small map - map_bottom_sheet"), styleUrl: _lightStyle, locale: "en", zoomPadding: const EdgeInsets.all(24), @@ -125,7 +126,7 @@ class _MapBottomSheetPageState extends State { target: LatLng(48.13, 11.62), zoom: 7.5, ), - noServiceAreaLayers: _noServiceAreaLayers(), + noServiceAreaLayers: noServiceAreaLayers(), onMapClicked: (_, _) {}, ), ), @@ -155,13 +156,7 @@ class _DetailedMapSheetState extends State<_DetailedMapSheet> { final _controller = RouteMapController(); double _heightFactor = 0.6; - String _styleUrl = _lightStyle; - - void _toggleStyle() { - setState(() { - _styleUrl = _styleUrl == _lightStyle ? _darkStyle : _lightStyle; - }); - } + final String _styleUrl = _lightStyle; @override Widget build(BuildContext context) { @@ -195,20 +190,13 @@ class _DetailedMapSheetState extends State<_DetailedMapSheet> { borderRadius: BorderRadius.circular(2), ), ), - Positioned( - right: 4, - child: IconButton( - tooltip: "Reload style", - onPressed: _toggleStyle, - icon: const Icon(Icons.refresh), - ), - ), ], ), ), ), Expanded( child: RouteMap( + key: const Key("map_bottom_sheet"), styleUrl: _styleUrl, locale: "en", zoomPadding: const EdgeInsets.all(40), @@ -217,7 +205,7 @@ class _DetailedMapSheetState extends State<_DetailedMapSheet> { target: LatLng(48.13, 11.62), zoom: 8.5, ), - noServiceAreaLayers: _noServiceAreaLayers(), + noServiceAreaLayers: noServiceAreaLayers(), onMapClicked: (_, _) {}, ), ), diff --git a/example/lib/examples/route_and_icons_example.dart b/example/lib/examples/route_and_icons_example.dart index 508e14b..8aad9da 100644 --- a/example/lib/examples/route_and_icons_example.dart +++ b/example/lib/examples/route_and_icons_example.dart @@ -1,3 +1,4 @@ +import 'package:example/examples/map_bottom_sheet.dart'; import 'package:flutter/material.dart'; import 'package:route_map/route_map.dart'; @@ -86,6 +87,7 @@ class _RouteAndIconsExampleState extends State { return Stack( children: [ RouteMap( + key: const Key("route and icons example - route_map"), minMaxZoomPreference: const MinMaxZoomPreference(5, 18), styleUrl: styleUrl, locale: "en", @@ -98,6 +100,7 @@ class _RouteAndIconsExampleState extends State { allowIconsOverlap: true, ignoreIconsPlacement: true, controller: _mapController, + noServiceAreaLayers: noServiceAreaLayers(), initialCameraPosition: const CameraPosition( target: LatLng(48.123287, 11.572062), zoom: 15, diff --git a/lib/src/route_map_base.dart b/lib/src/route_map_base.dart index faaa8f5..e545c48 100644 --- a/lib/src/route_map_base.dart +++ b/lib/src/route_map_base.dart @@ -286,7 +286,7 @@ class _RouteMapState extends State { unawaited(_setOverlap()); - await _scheduleGeometryRedraw(); + await _restoreAllGeometry(); if (!_fullyLoadedCompleter.isCompleted) { _fullyLoadedCompleter.complete(); @@ -308,7 +308,7 @@ class _RouteMapState extends State { await controller.setMapLanguage(widget.locale); } - Future _scheduleGeometryRedraw() async { + Future _restoreAllGeometry() async { // Use [_lineManagerInstance] directly instead of [_lineManager] // because _fullyLoadedCompleter is not completed yet final lineManager = _lineManagerInstance; From 110d234ba706b9d248a0f46acdf5ca83cd713313 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 3 Jul 2026 08:38:40 +0200 Subject: [PATCH 03/16] fix caching with an icon key --- .../route_map_icon_manager.dart | 38 +++++++++++++------ .../model/route_map_icon/route_map_icon.dart | 31 +++++++++++++++ .../route_map_icon.freezed.dart | 4 +- lib/src/poi_layer_extension.dart | 12 +++--- 4 files changed, 64 insertions(+), 21 deletions(-) diff --git a/lib/src/annotation_manager/route_map_icon_manager.dart b/lib/src/annotation_manager/route_map_icon_manager.dart index 2667a19..7519a5d 100644 --- a/lib/src/annotation_manager/route_map_icon_manager.dart +++ b/lib/src/annotation_manager/route_map_icon_manager.dart @@ -15,9 +15,13 @@ class RouteMapIconManager { // ⚠️ the values are the scaling and not the actual size! double get iconScale => kIsWeb ? 0.5 : 1.5; - /// There is no need to create the same icon image multiple times, - /// that why we cache the hashcode of [RouteMapIcon]. - final _cachedImages = []; + /// Cache of MapLibre image keys that have already been registered via + /// [MapLibreMapController.addImage]. The key is derived from the visual + /// properties of a [RouteMapIcon] (see [_imageKeyFor]) so that multiple + /// icons sharing the same visuals (e.g. all via markers) reuse the same + /// registered image instead of trying to re-add it under a colliding + /// identifier. + final _cachedImages = {}; /// The key is the symbol id by maplibre /// the value is the icon object from the user. @@ -39,16 +43,23 @@ class RouteMapIconManager { } // inspiration from: https://stackoverflow.com/a/78289149 - Future addImageToCacheIfNeeded( + Future addImageToCacheIfNeeded( MapLibreMapController controller, { required RouteMapIcon mapIcon, }) async { - if (_cachedImages.contains(mapIcon.hashCode)) return; + final imageKey = mapIcon.imageKey; + if (_cachedImages.contains(imageKey)) return imageKey; final uint8List = await _generatePngMarker(mapIcon: mapIcon); - if (controller.isDisposed) return; - await controller.addImage(mapIcon.identifier, uint8List); - _cachedImages.add(mapIcon.hashCode); + + if (controller.isDisposed) return imageKey; + // Re-check after the async gap in case another concurrent call + // added the same image in the meantime. + if (_cachedImages.contains(imageKey)) return imageKey; + + await controller.addImage(imageKey, uint8List); + _cachedImages.add(imageKey); + return imageKey; } Future removeIconsWhere(bool Function(RouteMapIcon icon) test) async { @@ -95,12 +106,15 @@ class RouteMapIconManager { } Future drawIcon(RouteMapIcon mapIcon) async { - await addImageToCacheIfNeeded(controller, mapIcon: mapIcon); + final imageKey = await addImageToCacheIfNeeded( + controller, + mapIcon: mapIcon, + ); if (controller.isDisposed) return; final existingSymbolEntry = _symbolMap.entries.firstWhereOrNull( (entry) => entry.value.identifier == mapIcon.identifier, ); - final symbolOptions = _buildSymbolOptions(mapIcon); + final symbolOptions = _buildSymbolOptions(mapIcon, imageKey); if (existingSymbolEntry != null) { final symbol = controller.symbols.firstWhereOrNull( @@ -119,13 +133,13 @@ class RouteMapIconManager { _symbolMap[symbol.id] = mapIcon; } - SymbolOptions _buildSymbolOptions(RouteMapIcon mapIcon) { + SymbolOptions _buildSymbolOptions(RouteMapIcon mapIcon, String imageKey) { final label = mapIcon.label; final hasLabel = label != null; return SymbolOptions( geometry: mapIcon.latLng, - iconImage: mapIcon.identifier, + iconImage: imageKey, iconAnchor: mapIcon.anchor.mglIconValue, iconSize: iconScale, iconRotate: mapIcon.rotationDegrees, diff --git a/lib/src/model/route_map_icon/route_map_icon.dart b/lib/src/model/route_map_icon/route_map_icon.dart index 0be8766..c7556e1 100644 --- a/lib/src/model/route_map_icon/route_map_icon.dart +++ b/lib/src/model/route_map_icon/route_map_icon.dart @@ -23,6 +23,37 @@ abstract class RouteMapIcon with _$RouteMapIcon { @Default(false) bool draggable, @Default(RouteMapIconAnchor.bottom) RouteMapIconAnchor anchor, }) = _RouteMapIcon; + + const RouteMapIcon._(); + + /// Stable key identifying the *rendered PNG* of this icon. Only fields + /// that actually influence the pixel output are taken into account so + /// that multiple icons sharing the same visuals (e.g. all via markers) + /// can be registered under the same MapLibre image and reused for + /// symbols at different [latLng]s. + /// + /// Excluded on purpose: [latLng], [identifier], [label] (drawn as a + /// separate text field), [rotationDegrees], [anchor] and [draggable]. + /// + /// [Path] has no content-based hash, so [identityHashCode] is used. + /// In practice callers reuse the same [Path] instance for the same + /// visual marker; if they don't, the worst case is a cache miss (an + /// additional PNG generation) — never a collision. + /// + /// Null fields are kept as explicit `0` / `''` placeholders (instead + /// of being filtered out) so that every field stays at a fixed + /// position in the joined string. Filtering nulls would let values + /// from different fields slide into the same slot and produce + /// colliding keys — e.g. `svgIconPath: 'foo'` (text null) and + /// `text: 'foo'` (svgIconPath null) would otherwise both collapse to + /// `"...|foo"` even though they render completely different PNGs. + String get imageKey => [ + identityHashCode(markerPath), + theme.hashCode, + darkTheme?.hashCode ?? 0, + svgIconPath ?? '', + text ?? '', + ].join('|'); } @freezed diff --git a/lib/src/model/route_map_icon/route_map_icon.freezed.dart b/lib/src/model/route_map_icon/route_map_icon.freezed.dart index bb2622d..b4a371d 100644 --- a/lib/src/model/route_map_icon/route_map_icon.freezed.dart +++ b/lib/src/model/route_map_icon/route_map_icon.freezed.dart @@ -236,8 +236,8 @@ return $default(_that.markerPath,_that.latLng,_that.identifier,_that.theme,_that /// @nodoc -class _RouteMapIcon implements RouteMapIcon { - const _RouteMapIcon({required this.markerPath, required this.latLng, required this.identifier, required this.theme, this.darkTheme, this.svgIconPath, this.text, this.label, this.rotationDegrees, this.draggable = false, this.anchor = RouteMapIconAnchor.bottom}): assert(svgIconPath == null || text == null, 'Either svgIcon or text must be provided, not both.'); +class _RouteMapIcon extends RouteMapIcon { + const _RouteMapIcon({required this.markerPath, required this.latLng, required this.identifier, required this.theme, this.darkTheme, this.svgIconPath, this.text, this.label, this.rotationDegrees, this.draggable = false, this.anchor = RouteMapIconAnchor.bottom}): assert(svgIconPath == null || text == null, 'Either svgIcon or text must be provided, not both.'),super._(); @override final Path markerPath; diff --git a/lib/src/poi_layer_extension.dart b/lib/src/poi_layer_extension.dart index 08d1132..9cd85f0 100644 --- a/lib/src/poi_layer_extension.dart +++ b/lib/src/poi_layer_extension.dart @@ -81,11 +81,7 @@ extension _RouteMapPoiLayerState on _RouteMapState { await controller.addSource(sourceId, source); if (!mounted) return; - // Register icon images for every category via the shared icon manager - // so POI markers go through the exact same rasterization pipeline as - // regular [RouteMapIcon]s. A synthetic [RouteMapIcon] template is used - // — only the markerPath, theme and svgIconPath are read by the - // rasterizer; latLng is ignored. + final categoryImageKeys = {}; for (final category in layer.categories) { final imageId = _poiCategoryImageId(layer: layer, category: category); final template = RouteMapIcon( @@ -97,10 +93,11 @@ extension _RouteMapPoiLayerState on _RouteMapState { svgIconPath: category.svgIconPath, anchor: category.anchor, ); - await _iconManagerInstance.addImageToCacheIfNeeded( + final imageKey = await _iconManagerInstance.addImageToCacheIfNeeded( controller, mapIcon: template, ); + categoryImageKeys[imageId] = imageKey; if (!mounted) return; } @@ -112,6 +109,7 @@ extension _RouteMapPoiLayerState on _RouteMapState { for (final category in layer.categories) { final imageId = _poiCategoryImageId(layer: layer, category: category); + final imageKey = categoryImageKeys[imageId] ?? imageId; final layerId = _poiCategoryLayerId(layer: layer, category: category); // Combine the user-provided filter with a "not clustered" check so @@ -144,7 +142,7 @@ extension _RouteMapPoiLayerState on _RouteMapState { sourceId, layerId, SymbolLayerProperties( - iconImage: imageId, + iconImage: imageKey, iconAnchor: category.anchor.mglIconValue, // Match the [RouteMapIconManager.iconScale] used for regular // icons so POI pins are rendered at the same size. From 58fac1e4cea27a171d705f5df6d0d5e279364dea Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 3 Jul 2026 09:35:38 +0200 Subject: [PATCH 04/16] handle onStyleLoaded called multiple times --- example/lib/examples/map_bottom_sheet.dart | 16 +- .../lib/examples/route_and_icons_example.dart | 2 +- lib/route_map.dart | 2 +- .../route_map_icon_manager.dart | 8 +- .../route_map_layer_manager.dart | 146 ++++++++++++++++++ ...rea_layer.dart => service_area_layer.dart} | 22 ++- lib/src/poi_layer_extension.dart | 90 +++-------- lib/src/route_map_base.dart | 73 ++++++--- lib/src/route_map_no_service_area_layer.dart | 140 ----------------- lib/src/route_map_service_area_layer.dart | 118 ++++++++++++++ lib/src/utils/coalescing_runner.dart | 49 ++++++ 11 files changed, 411 insertions(+), 255 deletions(-) create mode 100644 lib/src/annotation_manager/route_map_layer_manager.dart rename lib/src/model/{no_service_area_layer.dart => service_area_layer.dart} (56%) delete mode 100644 lib/src/route_map_no_service_area_layer.dart create mode 100644 lib/src/route_map_service_area_layer.dart create mode 100644 lib/src/utils/coalescing_runner.dart diff --git a/example/lib/examples/map_bottom_sheet.dart b/example/lib/examples/map_bottom_sheet.dart index ed9a30e..1be0f36 100644 --- a/example/lib/examples/map_bottom_sheet.dart +++ b/example/lib/examples/map_bottom_sheet.dart @@ -2,13 +2,13 @@ import 'package:flutter/material.dart'; import 'package:route_map/route_map.dart'; /// A small map plus a button that opens a draggable bottom sheet containing a -/// more detailed map. Both maps render the same (fairly complex) no-service +/// more detailed map. Both maps render the same (fairly complex) service /// area so the layer setup runs on every style load. /// /// Dragging / resizing the sheet embeds the map in an animated container and /// the refresh button reloads the style at runtime — both re-fire /// `onStyleLoadedCallback`, which is what surfaces issue #13 -/// (`sourceAlreadyExists` for `no_service_area_source_id_0`). +/// (`sourceAlreadyExists` for `service_area_source_id_0`). const _lightStyle = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; const _darkStyle = @@ -70,15 +70,15 @@ Future createServiceAreaSource() async { return const GeojsonSourceProperties(data: serviceAreaGeoJson); } -List noServiceAreaLayers() => [ - NoServiceAreaLayer( +List serviceAreaLayers() => [ + ServiceAreaLayer( createSource: createServiceAreaSource, fillColor: Colors.grey.withValues(alpha: 0.45), - hashLines: const NoServiceAreaHashLines( + hashLines: const ServiceAreaHashLines( color: Colors.black26, spacing: 10, ), - border: const NoServiceAreaBorder( + border: const ServiceAreaBorder( createSource: createServiceAreaSource, color: Colors.redAccent, width: 2, @@ -126,7 +126,7 @@ class _MapBottomSheetPageState extends State { target: LatLng(48.13, 11.62), zoom: 7.5, ), - noServiceAreaLayers: noServiceAreaLayers(), + serviceAreaLayers: serviceAreaLayers(), onMapClicked: (_, _) {}, ), ), @@ -205,7 +205,7 @@ class _DetailedMapSheetState extends State<_DetailedMapSheet> { target: LatLng(48.13, 11.62), zoom: 8.5, ), - noServiceAreaLayers: noServiceAreaLayers(), + serviceAreaLayers: serviceAreaLayers(), onMapClicked: (_, _) {}, ), ), diff --git a/example/lib/examples/route_and_icons_example.dart b/example/lib/examples/route_and_icons_example.dart index 8aad9da..13773b5 100644 --- a/example/lib/examples/route_and_icons_example.dart +++ b/example/lib/examples/route_and_icons_example.dart @@ -100,7 +100,7 @@ class _RouteAndIconsExampleState extends State { allowIconsOverlap: true, ignoreIconsPlacement: true, controller: _mapController, - noServiceAreaLayers: noServiceAreaLayers(), + serviceAreaLayers: serviceAreaLayers(), initialCameraPosition: const CameraPosition( target: LatLng(48.123287, 11.572062), zoom: 15, diff --git a/lib/route_map.dart b/lib/route_map.dart index 2d3b3fe..22ae3b7 100644 --- a/lib/route_map.dart +++ b/lib/route_map.dart @@ -14,6 +14,6 @@ export 'src/model/route_map_icon_anchor.dart'; export 'src/model/route_map_circle/route_map_circle.dart'; export 'src/model/route_map_route/route_map_route.dart'; export 'src/route_map_base.dart'; -export 'src/model/no_service_area_layer.dart'; +export 'src/model/service_area_layer.dart'; export 'src/model/route_map_poi_layer.dart'; export 'src/model/route_map_user_location_indicator/route_map_user_location_indicator.dart'; diff --git a/lib/src/annotation_manager/route_map_icon_manager.dart b/lib/src/annotation_manager/route_map_icon_manager.dart index 7519a5d..c5dee9a 100644 --- a/lib/src/annotation_manager/route_map_icon_manager.dart +++ b/lib/src/annotation_manager/route_map_icon_manager.dart @@ -17,10 +17,10 @@ class RouteMapIconManager { /// Cache of MapLibre image keys that have already been registered via /// [MapLibreMapController.addImage]. The key is derived from the visual - /// properties of a [RouteMapIcon] (see [_imageKeyFor]) so that multiple - /// icons sharing the same visuals (e.g. all via markers) reuse the same - /// registered image instead of trying to re-add it under a colliding - /// identifier. + /// properties of a [RouteMapIcon] (see [RouteMapIcon.imageKey]) so that + /// multiple icons sharing the same visuals (e.g. all via markers) + /// reuse the same registered image instead of trying to re-add it + /// under a colliding identifier. final _cachedImages = {}; /// The key is the symbol id by maplibre diff --git a/lib/src/annotation_manager/route_map_layer_manager.dart b/lib/src/annotation_manager/route_map_layer_manager.dart new file mode 100644 index 0000000..0578540 --- /dev/null +++ b/lib/src/annotation_manager/route_map_layer_manager.dart @@ -0,0 +1,146 @@ +import 'dart:typed_data'; + +import 'package:maplibre_gl/maplibre_gl.dart'; + +/// Tracks every *style-scoped* resource (source, layer, image) that has +/// been attached to the map, so it can be cleanly removed and rebuilt +/// whenever MapLibre reports a fresh style — for example after a theme +/// / style-url change, or when `onStyleLoadedCallback` fires more than +/// once during construction (which MapLibre does). +/// +/// This manager is **content-agnostic**: it does not know about service +/// areas, POI layers or anything else. Consumers (extensions on +/// `_RouteMapState`) go through this manager instead of calling +/// `controller.addSource` / `addLayer` / `addImage` directly, so that a +/// single [removeAll] call in `onStyleLoadedCallback` can safely tear +/// everything down before re-installation without any per-feature +/// bookkeeping duplicated across extensions. +/// +/// All add operations are idempotent: registering the same id twice +/// silently no-ops. This makes callers robust against MapLibre firing +/// `onStyleLoadedCallback` twice in a row before [removeAll] has been +/// called. +class RouteMapLayerManager { + final MapLibreMapController controller; + + final Set _layerIds = {}; + final Set _sourceIds = {}; + final Set _imageIds = {}; + + /// Cache of resolved insert-below anchors keyed by the [belowLayerId] + /// hint the caller passed to [addLayer]. Cleared in [removeAll]. Safe + /// to cache within one install run because the resolved anchor is + /// always one of the annotation-manager layers, which the manager + /// itself never touches. + final Map _insertBelowCache = {}; + + RouteMapLayerManager({required this.controller}); + + Future addSource(String id, SourceProperties properties) async { + if (_sourceIds.contains(id)) return; + await controller.addSource(id, properties); + _sourceIds.add(id); + } + + /// Adds a style layer. When [belowLayerId] is provided it is + /// preferred as insertion anchor; otherwise the first annotation- + /// manager layer (lines / symbols / circles / fills) becomes the + /// anchor so custom layers stay below drawn routes and icons. + /// + /// Note: [belowLayerId] here means "prefer this as anchor if it + /// exists" — it is *not* forwarded verbatim to + /// [MapLibreMapController.addLayer]. The manager always resolves an + /// annotation-manager-aware anchor so custom layers never render on + /// top of drawn geometry. + Future addLayer( + String sourceId, + String layerId, + LayerProperties properties, { + String? belowLayerId, + // Mirror maplibre_gl's own default (true) so callers that don't + // explicitly opt out keep the same behavior as before. + bool enableInteraction = true, + dynamic filter, + }) async { + if (_layerIds.contains(layerId)) return; + final resolvedBelowLayerId = await _resolveInsertBelowLayer( + belowLayerId: belowLayerId, + ); + await controller.addLayer( + sourceId, + layerId, + properties, + belowLayerId: resolvedBelowLayerId, + enableInteraction: enableInteraction, + filter: filter, + ); + _layerIds.add(layerId); + } + + Future addImage(String id, Uint8List bytes) async { + if (_imageIds.contains(id)) return; + await controller.addImage(id, bytes); + _imageIds.add(id); + } + + /// Tears down every source & layer this manager has attached to the + /// map. Individual failures are swallowed because a style change may + /// have already dropped some of them on the native side. + /// + /// Images are *not* explicitly removed: the maplibre_gl Dart API + /// does not expose a `removeImage`, and MapLibre wipes all style + /// images automatically when a new style is loaded. We only clear + /// our tracking set so the next [addImage] re-registers them (which + /// safely overwrites the — by then absent — native entry). + Future removeAll() async { + _insertBelowCache.clear(); + + if (controller.isDisposed) { + _layerIds.clear(); + _sourceIds.clear(); + _imageIds.clear(); + return; + } + + for (final id in _layerIds.toList()) { + try { + await controller.removeLayer(id); + } catch (_) { + /* ignore */ + } + } + _layerIds.clear(); + + for (final id in _sourceIds.toList()) { + try { + await controller.removeSource(id); + } catch (_) { + /* ignore */ + } + } + _sourceIds.clear(); + + _imageIds.clear(); + } + + Future _resolveInsertBelowLayer({ + required String? belowLayerId, + }) async { + final cached = _insertBelowCache[belowLayerId]; + if (cached != null) return cached; + + final topLayers = [ + ...controller.lineManager!.layerIds, + ...controller.symbolManager!.layerIds, + ...controller.circleManager!.layerIds, + ...controller.fillManager!.layerIds, + ?belowLayerId, + ]; + final allLayerIds = await controller.getLayerIds(); + final resolved = allLayerIds + .map((layerId) => layerId.toString()) + .firstWhere((layerId) => topLayers.contains(layerId)); + _insertBelowCache[belowLayerId] = resolved; + return resolved; + } +} diff --git a/lib/src/model/no_service_area_layer.dart b/lib/src/model/service_area_layer.dart similarity index 56% rename from lib/src/model/no_service_area_layer.dart rename to lib/src/model/service_area_layer.dart index 9e8a754..08fd8eb 100644 --- a/lib/src/model/no_service_area_layer.dart +++ b/lib/src/model/service_area_layer.dart @@ -2,14 +2,20 @@ import 'dart:ui'; import 'package:maplibre_gl/maplibre_gl.dart'; -class NoServiceAreaLayer { +/// Defines a shaded region on the map — typically used to visualise an +/// area that is *not* part of the operator's service coverage. +/// +/// The fill is rendered below all annotation-manager layers (lines, +/// symbols, circles, fills) so drawn routes and icons remain fully +/// visible on top of it. +class ServiceAreaLayer { final Future Function() createSource; final Color fillColor; - final NoServiceAreaHashLines? hashLines; - final NoServiceAreaBorder? border; + final ServiceAreaHashLines? hashLines; + final ServiceAreaBorder? border; final String? belowLayerId; - const NoServiceAreaLayer({ + const ServiceAreaLayer({ required this.createSource, required this.fillColor, this.hashLines, @@ -18,12 +24,12 @@ class NoServiceAreaLayer { }); } -class NoServiceAreaHashLines { +class ServiceAreaHashLines { final Color color; final double width; final double spacing; - const NoServiceAreaHashLines({ + const ServiceAreaHashLines({ required this.color, this.width = 1, this.spacing = 8, @@ -31,12 +37,12 @@ class NoServiceAreaHashLines { assert(spacing > 0); } -class NoServiceAreaBorder { +class ServiceAreaBorder { final Future Function() createSource; final Color color; final double width; - const NoServiceAreaBorder({ + const ServiceAreaBorder({ required this.createSource, required this.color, this.width = 1, diff --git a/lib/src/poi_layer_extension.dart b/lib/src/poi_layer_extension.dart index 9cd85f0..64ca9dc 100644 --- a/lib/src/poi_layer_extension.dart +++ b/lib/src/poi_layer_extension.dart @@ -1,7 +1,10 @@ part of 'route_map_base.dart'; /// Internal book-keeping for a single [RouteMapPoiLayer] that has been -/// materialised on the map. +/// materialised on the map. The manager only tracks *raw* sources / +/// layers / images — this struct captures the higher-level grouping we +/// need for POI-specific operations like visibility toggling and tap +/// routing. class _PoiLayerEntry { final RouteMapPoiLayer layer; final String sourceId; @@ -23,8 +26,6 @@ class _PoiLayerEntry { } extension _RouteMapPoiLayerState on _RouteMapState { - /// Wires up every declared [RouteMapPoiLayer] on the freshly-loaded - /// style. Must be called from `onStyleLoadedCallback`. Future _addPoiLayers() async { final poiLayers = widget.poiLayers; if (poiLayers.isEmpty) return; @@ -37,50 +38,22 @@ extension _RouteMapPoiLayerState on _RouteMapState { } } - /// Removes all currently-installed POI layers. Used both for explicit - /// removal and on style change to clean up before re-installing. - Future _removeAllPoiLayers() async { - if (_poiLayers.isEmpty) return; - final controller = await _controller; - if (!mounted) return; - - for (final entry in _poiLayers.values.toList()) { - await _removePoiLayerEntry(controller: controller, entry: entry); - if (!mounted) return; - } - } - Future _addPoiLayer({ required MapLibreMapController controller, required RouteMapPoiLayer layer, }) async { - // Resolve the layer-id below which we insert all POI sub-layers. Mirrors - // the logic of `_addNoServiceAreaLayer` to keep symbol manager layers - // above the inserted POI layers. - final belowLayerId = layer.belowLayerId; - - final topLayers = [ - ...controller.lineManager!.layerIds, - ...controller.symbolManager!.layerIds, - ...controller.circleManager!.layerIds, - ...controller.fillManager!.layerIds, - ?belowLayerId, - ]; - final allLayerIds = await controller.getLayerIds(); - final insertBelowLayer = allLayerIds - .map((layerId) => layerId.toString()) - .firstWhere((layerId) => topLayers.contains(layerId)); - - if (!mounted) return; - - // Load the GeoJSON source. final source = await layer.createSource(); if (!mounted) return; final sourceId = "route_map_poi_source_${layer.identifier}"; - await controller.addSource(sourceId, source); + await _layerManagerInstance.addSource(sourceId, source); if (!mounted) return; + // Register PNGs via the shared icon manager so POI markers go + // through the exact same rasterization pipeline as regular icons. + // The manager returns the actual MapLibre image key it registered + // the PNG under (derived from visuals), which we reuse as + // `iconImage` for the SymbolLayer below. final categoryImageKeys = {}; for (final category in layer.categories) { final imageId = _poiCategoryImageId(layer: layer, category: category); @@ -101,10 +74,8 @@ extension _RouteMapPoiLayerState on _RouteMapState { if (!mounted) return; } - // Add a SymbolLayer per category. final categoryLayerIds = []; final categoryById = {}; - final brightness = MediaQuery.platformBrightnessOf(context); for (final category in layer.categories) { @@ -126,7 +97,6 @@ extension _RouteMapPoiLayerState on _RouteMapState { final labelDef = category.label; final hasLabel = labelDef != null; - // Resolve theme-aware label colors. final labelColor = labelDef == null ? null : (brightness == Brightness.dark @@ -138,14 +108,12 @@ extension _RouteMapPoiLayerState on _RouteMapState { ? (labelDef.darkHaloColor ?? labelDef.haloColor) : labelDef.haloColor); - await controller.addLayer( + await _layerManagerInstance.addLayer( sourceId, layerId, SymbolLayerProperties( iconImage: imageKey, iconAnchor: category.anchor.mglIconValue, - // Match the [RouteMapIconManager.iconScale] used for regular - // icons so POI pins are rendered at the same size. iconSize: _iconManagerInstance.iconScale, iconAllowOverlap: widget.allowIconsOverlap, iconIgnorePlacement: widget.ignoreIconsPlacement, @@ -158,7 +126,7 @@ extension _RouteMapPoiLayerState on _RouteMapState { textHaloColor: labelHaloColor?.toHexStringRGB(), textHaloWidth: hasLabel ? labelDef.haloWidth : null, ), - belowLayerId: insertBelowLayer, + belowLayerId: layer.belowLayerId, enableInteraction: category.interactive, filter: filter, ); @@ -168,9 +136,9 @@ extension _RouteMapPoiLayerState on _RouteMapState { categoryById[layerId] = category; } - // Cluster appearance — single circle layer + count text layer, inserted - // *above* the categories so they always paint on top of individual - // features (mirroring the maplibre clustering example). + // Cluster appearance — inserted *above* the categories so they + // always paint on top of individual features (mirroring the + // maplibre clustering example). final clusterLayerIds = []; final clusterTheme = layer.clusterTheme; if (clusterTheme != null) { @@ -188,7 +156,7 @@ extension _RouteMapPoiLayerState on _RouteMapState { ? (clusterTheme.darkTextColor ?? clusterTheme.textColor) : clusterTheme.textColor; - await controller.addLayer( + await _layerManagerInstance.addLayer( sourceId, circleId, CircleLayerProperties( @@ -199,12 +167,12 @@ extension _RouteMapPoiLayerState on _RouteMapState { circleStrokeColor: circleStrokeColor.toHexStringRGB(), circleStrokeWidth: clusterTheme.circleStrokeWidth, ), - belowLayerId: insertBelowLayer, + belowLayerId: layer.belowLayerId, filter: ['has', 'point_count'], ); if (!mounted) return; - await controller.addLayer( + await _layerManagerInstance.addLayer( sourceId, textId, SymbolLayerProperties( @@ -213,7 +181,7 @@ extension _RouteMapPoiLayerState on _RouteMapState { // ignore: deprecated_member_use textColor: textColor.toHexStringRGB(), ), - belowLayerId: insertBelowLayer, + belowLayerId: layer.belowLayerId, filter: ['has', 'point_count'], ); if (!mounted) return; @@ -240,26 +208,6 @@ extension _RouteMapPoiLayerState on _RouteMapState { } } - Future _removePoiLayerEntry({ - required MapLibreMapController controller, - required _PoiLayerEntry entry, - }) async { - for (final layerId in entry.allLayerIds) { - try { - await controller.removeLayer(layerId); - } catch (_) { - // The layer might already be gone after a style change — ignore. - } - if (!mounted) return; - } - try { - await controller.removeSource(entry.sourceId); - } catch (_) { - // Same here. - } - _poiLayers.remove(entry.layer.identifier); - } - Future _applyPoiLayerVisibility({ required MapLibreMapController controller, required _PoiLayerEntry entry, diff --git a/lib/src/route_map_base.dart b/lib/src/route_map_base.dart index e545c48..91dd9e4 100644 --- a/lib/src/route_map_base.dart +++ b/lib/src/route_map_base.dart @@ -9,12 +9,16 @@ import 'package:route_map/route_map.dart'; import 'package:route_map/src/coordinator/route_map_location_indicator_coordinator.dart'; import 'package:route_map/src/annotation_manager/route_map_circle_manager.dart'; import 'package:route_map/src/annotation_manager/route_map_icon_manager.dart'; +import 'package:route_map/src/annotation_manager/route_map_layer_manager.dart'; import 'package:route_map/src/annotation_manager/route_map_line_manager.dart'; import 'package:route_map/src/route_map_camera_update.dart'; import 'package:route_map/src/route_map_geometry_extension.dart'; +import 'package:route_map/src/utils/coalescing_runner.dart'; part 'route_map_controller.dart'; -part 'route_map_no_service_area_layer.dart'; + +part 'route_map_service_area_layer.dart'; + part 'poi_layer_extension.dart'; class RouteMap extends StatefulWidget { @@ -25,7 +29,7 @@ class RouteMap extends StatefulWidget { final String styleUrl; final CameraTargetBounds? cameraTargetBounds; final MinMaxZoomPreference? minMaxZoomPreference; - final List noServiceAreaLayers; + final List serviceAreaLayers; final RouteMapController controller; final bool trackCameraPosition; final VoidCallback? onCameraMoveStarted; @@ -69,7 +73,7 @@ class RouteMap extends StatefulWidget { required this.styleUrl, this.cameraTargetBounds, this.minMaxZoomPreference, - this.noServiceAreaLayers = const [], + this.serviceAreaLayers = const [], this.poiLayers = const [], this.trackCameraPosition = false, this.onCameraMoveStarted, @@ -91,12 +95,28 @@ class _RouteMapState extends State { bool _wasCameraMoving = false; VoidCallback? _cameraStateListener; + /// Coordinates concurrent `onStyleLoadedCallback` invocations. + /// MapLibre fires the callback on initial construction *and* on + /// every subsequent style change (theme switch, style-url reload), + /// sometimes twice in quick succession. Overlapping installs would + /// race the shared layer state, so the runner ensures at most one + /// install runs at a time and folds any fires that arrive + /// mid-install into a single trailing rerun — enough to pick up + /// whatever caused the latest fire without executing every fire + /// individually. + late final CoalescingRunner _installRunner = CoalescingRunner( + _installStyleContent, + shouldRerun: () => mounted, + ); + late final RouteMapIconManager _iconManagerInstance; late final RouteMapLineManager _lineManagerInstance; late final RouteMapCircleManager _circleManagerInstance; + late final RouteMapLayerManager _layerManagerInstance; + late final RouteMapLocationIndicatorCoordinator _locationIndicatorCoordinatorInstance; @@ -252,6 +272,7 @@ class _RouteMapState extends State { _circleManagerInstance = RouteMapCircleManager( controller: controller, ); + _layerManagerInstance = RouteMapLayerManager(controller: controller); _locationIndicatorCoordinatorInstance = RouteMapLocationIndicatorCoordinator( circleManager: _circleManagerInstance, @@ -273,25 +294,7 @@ class _RouteMapState extends State { controller.onFeatureHover.add(_onFeatureHover); } }, - onStyleLoadedCallback: () async { - // Drop any POI layers we set up for the previous style so the - // following `_addPoiLayers` rebuild can re-use the same identifiers. - await _removeAllPoiLayers(); - - await _addNoServiceAreaLayers(); - - await _addPoiLayers(); - - await _setMapLanguage(); - - unawaited(_setOverlap()); - - await _restoreAllGeometry(); - - if (!_fullyLoadedCompleter.isCompleted) { - _fullyLoadedCompleter.complete(); - } - }, + onStyleLoadedCallback: _installRunner.schedule, annotationOrder: const [ AnnotationType.fill, AnnotationType.line, @@ -302,6 +305,32 @@ class _RouteMapState extends State { ); } + Future _installStyleContent() async { + if (!mounted) return; + // Remove anything a previous style-load left behind. The layer + // manager makes this a no-op on the very first invocation and + // handles missing-native-side entries gracefully after a style + // change. + _poiLayers.clear(); + await _layerManagerInstance.removeAll(); + if (!mounted) return; + + await _addServiceAreaLayers(); + if (!mounted) return; + await _addPoiLayers(); + if (!mounted) return; + + await _setMapLanguage(); + + unawaited(_setOverlap()); + + await _restoreAllGeometry(); + + if (!_fullyLoadedCompleter.isCompleted) { + _fullyLoadedCompleter.complete(); + } + } + Future _setMapLanguage() async { final controller = await _controller; if (!mounted) return; diff --git a/lib/src/route_map_no_service_area_layer.dart b/lib/src/route_map_no_service_area_layer.dart deleted file mode 100644 index 2a7111a..0000000 --- a/lib/src/route_map_no_service_area_layer.dart +++ /dev/null @@ -1,140 +0,0 @@ -part of 'route_map_base.dart'; - -extension _RouteMapNoServiceAreaLayerState on _RouteMapState { - Future _addNoServiceAreaLayers() async { - final noServiceAreaLayers = widget.noServiceAreaLayers; - if (noServiceAreaLayers.isEmpty) return; - final controller = await _controller; - if (!mounted) return; - - for (var index = 0; index < noServiceAreaLayers.length; index++) { - await _addNoServiceAreaLayer( - controller: controller, - noServiceAreaLayer: noServiceAreaLayers[index], - index: index, - ); - } - } - - Future _addNoServiceAreaLayer({ - required MapLibreMapController controller, - required NoServiceAreaLayer noServiceAreaLayer, - required int index, - }) async { - /// The no service layer needs to be below the manager layers, to show - /// all lines, icons, images above the grey layer and not below. - final belowLayerId = noServiceAreaLayer.belowLayerId; - - final topLayers = [ - ...controller.lineManager!.layerIds, - ...controller.symbolManager!.layerIds, - ...controller.circleManager!.layerIds, - ...controller.fillManager!.layerIds, - ?belowLayerId, - ]; - final allLayerIds = await controller.getLayerIds(); - - final insertBelowLayer = allLayerIds - .map((layerId) => layerId.toString()) - .firstWhere((layerId) => topLayers.contains(layerId)); - - if (!mounted) return; - - final source = await noServiceAreaLayer.createSource(); - - if (!mounted) return; - - final sourceId = "no_service_area_source_id_$index"; - await controller.addSource(sourceId, source); - if (!mounted) return; - - final hashLines = noServiceAreaLayer.hashLines; - - await controller.addLayer( - sourceId, - "no_service_area_layer_id_$index", - FillLayerProperties( - fillColor: noServiceAreaLayer.fillColor.toHexStringRGB(), - fillOpacity: noServiceAreaLayer.fillColor.a, - ), - belowLayerId: insertBelowLayer, - enableInteraction: false, - ); - - if (hashLines != null) { - final patternBytes = await _createHashLinesPattern(hashLines: hashLines); - if (!mounted) return; - final patternId = "no_service_area_hash_lines_$index"; - await controller.addImage(patternId, patternBytes); - if (!mounted) return; - - if (!mounted) return; - - await controller.addLayer( - sourceId, - "no_service_area_hash_lines_layer_id_$index", - FillLayerProperties(fillOpacity: 1, fillPattern: patternId), - belowLayerId: insertBelowLayer, - enableInteraction: false, - ); - } - - final border = noServiceAreaLayer.border; - if (border == null) return; - - if (!mounted) return; - - final borderSource = await border.createSource(); - if (!mounted) return; - - final borderSourceId = "no_service_area_border_source_id_$index"; - await controller.addSource(borderSourceId, borderSource); - if (!mounted) return; - - await controller.addLayer( - borderSourceId, - "no_service_area_border_layer_id_$index", - LineLayerProperties( - lineColor: border.color.toHexStringRGB(), - lineOpacity: border.color.a, - lineWidth: border.width, - ), - belowLayerId: insertBelowLayer, - enableInteraction: false, - ); - } -} - -Future _createHashLinesPattern({ - required NoServiceAreaHashLines hashLines, -}) async { - final tileSize = max(32, (hashLines.spacing * 4).ceil()); - final tileDimension = tileSize.toDouble(); - final recorder = ui.PictureRecorder(); - final canvas = ui.Canvas(recorder); - - final linePaint = ui.Paint() - ..color = hashLines.color - ..strokeWidth = hashLines.width - ..style = ui.PaintingStyle.stroke; - - for ( - var offset = -tileDimension; - offset <= tileDimension * 2; - offset += hashLines.spacing - ) { - canvas.drawLine( - ui.Offset(offset, tileDimension), - ui.Offset(offset + tileDimension, 0), - linePaint, - ); - } - - final image = await recorder.endRecording().toImage(tileSize, tileSize); - final byteData = await image.toByteData(format: ui.ImageByteFormat.png); - if (byteData == null) { - throw StateError("Failed to create no service area hash lines pattern"); - } - - return byteData.buffer.asUint8List(); -} diff --git a/lib/src/route_map_service_area_layer.dart b/lib/src/route_map_service_area_layer.dart new file mode 100644 index 0000000..218a4a0 --- /dev/null +++ b/lib/src/route_map_service_area_layer.dart @@ -0,0 +1,118 @@ +part of 'route_map_base.dart'; + +extension _RouteMapServiceAreaLayerState on _RouteMapState { + Future _addServiceAreaLayers() async { + final serviceAreaLayers = widget.serviceAreaLayers; + if (serviceAreaLayers.isEmpty) return; + final controller = await _controller; + if (!mounted) return; + + for (var index = 0; index < serviceAreaLayers.length; index++) { + await _addServiceAreaLayer( + controller: controller, + serviceAreaLayer: serviceAreaLayers[index], + index: index, + ); + if (!mounted) return; + } + } + + Future _addServiceAreaLayer({ + required MapLibreMapController controller, + required ServiceAreaLayer serviceAreaLayer, + required int index, + }) async { + final source = await serviceAreaLayer.createSource(); + if (!mounted) return; + + final sourceId = "service_area_source_id_$index"; + await _layerManagerInstance.addSource(sourceId, source); + if (!mounted) return; + + await _layerManagerInstance.addLayer( + sourceId, + "service_area_layer_id_$index", + FillLayerProperties( + fillColor: serviceAreaLayer.fillColor.toHexStringRGB(), + fillOpacity: serviceAreaLayer.fillColor.a, + ), + belowLayerId: serviceAreaLayer.belowLayerId, + enableInteraction: false, + ); + if (!mounted) return; + + final hashLines = serviceAreaLayer.hashLines; + if (hashLines != null) { + final patternBytes = await _createHashLinesPattern(hashLines: hashLines); + if (!mounted) return; + final patternId = "service_area_hash_lines_$index"; + await _layerManagerInstance.addImage(patternId, patternBytes); + if (!mounted) return; + + await _layerManagerInstance.addLayer( + sourceId, + "service_area_hash_lines_layer_id_$index", + FillLayerProperties(fillOpacity: 1, fillPattern: patternId), + belowLayerId: serviceAreaLayer.belowLayerId, + enableInteraction: false, + ); + if (!mounted) return; + } + + final border = serviceAreaLayer.border; + if (border == null) return; + + final borderSource = await border.createSource(); + if (!mounted) return; + + final borderSourceId = "service_area_border_source_id_$index"; + await _layerManagerInstance.addSource(borderSourceId, borderSource); + if (!mounted) return; + + await _layerManagerInstance.addLayer( + borderSourceId, + "service_area_border_layer_id_$index", + LineLayerProperties( + lineColor: border.color.toHexStringRGB(), + lineOpacity: border.color.a, + lineWidth: border.width, + ), + belowLayerId: serviceAreaLayer.belowLayerId, + enableInteraction: false, + ); + } +} + +Future _createHashLinesPattern({ + required ServiceAreaHashLines hashLines, +}) async { + final tileSize = max(32, (hashLines.spacing * 4).ceil()); + final tileDimension = tileSize.toDouble(); + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + + final linePaint = ui.Paint() + ..color = hashLines.color + ..strokeWidth = hashLines.width + ..style = ui.PaintingStyle.stroke; + + for ( + var offset = -tileDimension; + offset <= tileDimension * 2; + offset += hashLines.spacing + ) { + canvas.drawLine( + ui.Offset(offset, tileDimension), + ui.Offset(offset + tileDimension, 0), + linePaint, + ); + } + + final image = await recorder.endRecording().toImage(tileSize, tileSize); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) { + throw StateError("Failed to create service area hash lines pattern"); + } + + return byteData.buffer.asUint8List(); +} diff --git a/lib/src/utils/coalescing_runner.dart b/lib/src/utils/coalescing_runner.dart new file mode 100644 index 0000000..91cecd3 --- /dev/null +++ b/lib/src/utils/coalescing_runner.dart @@ -0,0 +1,49 @@ +import 'dart:async'; + +/// Runs an async task and coalesces overlapping schedule requests. +/// +/// * If the runner is idle, [schedule] immediately starts the task. +/// * If the runner is already running, [schedule] just flags a rerun +/// and returns — it never spawns a second concurrent execution. +/// * Any number of [schedule] calls while a task is in flight collapse +/// into a single extra run once the current one finishes. +/// +/// Useful for reacting to callbacks that may fire faster than the task +/// takes to complete (e.g. MapLibre's `onStyleLoadedCallback`), when +/// running the task twice concurrently would race shared state but +/// missing a late fire would leave the app out of sync. +class CoalescingRunner { + final Future Function() _task; + final bool Function()? _shouldRerun; + + bool _isRunning = false; + bool _rerunPending = false; + + /// [task] is the work to run. [shouldRerun] is an optional guard + /// consulted before every rerun (e.g. `() => mounted`) — return + /// `false` to abort the pending rerun. + CoalescingRunner(this._task, {bool Function()? shouldRerun}) + : _shouldRerun = shouldRerun; + + bool get isRunning => _isRunning; + + /// Schedules the task. Safe to call from anywhere (including from + /// synchronous callbacks) — the returned future never throws and + /// completes when either the current run (or its rerun, if this + /// call triggered one) is done. + Future schedule() async { + if (_isRunning) { + _rerunPending = true; + return; + } + _isRunning = true; + try { + do { + _rerunPending = false; + await _task(); + } while (_rerunPending && (_shouldRerun?.call() ?? true)); + } finally { + _isRunning = false; + } + } +} From a5eef9c37770ef87a879998d655e49588a63f4a9 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 3 Jul 2026 09:58:44 +0200 Subject: [PATCH 05/16] fix style --- example/lib/examples/map_bottom_sheet.dart | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/example/lib/examples/map_bottom_sheet.dart b/example/lib/examples/map_bottom_sheet.dart index 1be0f36..eb36395 100644 --- a/example/lib/examples/map_bottom_sheet.dart +++ b/example/lib/examples/map_bottom_sheet.dart @@ -74,10 +74,7 @@ List serviceAreaLayers() => [ ServiceAreaLayer( createSource: createServiceAreaSource, fillColor: Colors.grey.withValues(alpha: 0.45), - hashLines: const ServiceAreaHashLines( - color: Colors.black26, - spacing: 10, - ), + hashLines: const ServiceAreaHashLines(color: Colors.black26, spacing: 10), border: const ServiceAreaBorder( createSource: createServiceAreaSource, color: Colors.redAccent, @@ -107,6 +104,10 @@ class _MapBottomSheetPageState extends State { @override Widget build(BuildContext context) { + final isLightTheme = Theme.of(context).brightness == Brightness.light; + + final styleUrl = isLightTheme ? _lightStyle : _darkStyle; + return Padding( padding: const EdgeInsets.all(16), child: Column( @@ -118,7 +119,7 @@ class _MapBottomSheetPageState extends State { height: 240, child: RouteMap( key: const Key("small map - map_bottom_sheet"), - styleUrl: _lightStyle, + styleUrl: styleUrl, locale: "en", zoomPadding: const EdgeInsets.all(24), controller: _smallMapController, @@ -126,7 +127,7 @@ class _MapBottomSheetPageState extends State { target: LatLng(48.13, 11.62), zoom: 7.5, ), - serviceAreaLayers: serviceAreaLayers(), + serviceAreaLayers: serviceAreaLayers(), onMapClicked: (_, _) {}, ), ), @@ -205,7 +206,7 @@ class _DetailedMapSheetState extends State<_DetailedMapSheet> { target: LatLng(48.13, 11.62), zoom: 8.5, ), - serviceAreaLayers: serviceAreaLayers(), + serviceAreaLayers: serviceAreaLayers(), onMapClicked: (_, _) {}, ), ), From eafa047e65dfa08e97882bfcfd91a556a07c2f37 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 3 Jul 2026 10:13:37 +0200 Subject: [PATCH 06/16] format code --- example/lib/main.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 0bb4da4..988d8cf 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -93,10 +93,7 @@ class _HomeShellState extends State { alignment: Alignment.bottomLeft, child: Text( "route_map examples", - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), ), ), From 668ae8bf4e7988aabca35e48793565584be3edc1 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 3 Jul 2026 11:13:29 +0200 Subject: [PATCH 07/16] fix mounted check --- lib/src/route_map_base.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/src/route_map_base.dart b/lib/src/route_map_base.dart index 91dd9e4..b8eb36a 100644 --- a/lib/src/route_map_base.dart +++ b/lib/src/route_map_base.dart @@ -307,10 +307,7 @@ class _RouteMapState extends State { Future _installStyleContent() async { if (!mounted) return; - // Remove anything a previous style-load left behind. The layer - // manager makes this a no-op on the very first invocation and - // handles missing-native-side entries gracefully after a style - // change. + _poiLayers.clear(); await _layerManagerInstance.removeAll(); if (!mounted) return; @@ -321,6 +318,7 @@ class _RouteMapState extends State { if (!mounted) return; await _setMapLanguage(); + if (!mounted) return; unawaited(_setOverlap()); From ccad1fabf4ea6ba6b28c0670695d7f7223c6de48 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 3 Jul 2026 11:24:47 +0200 Subject: [PATCH 08/16] fix layer visisbility --- .../annotation_manager/route_map_layer_manager.dart | 10 ++++++++++ lib/src/poi_layer_extension.dart | 11 +++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/lib/src/annotation_manager/route_map_layer_manager.dart b/lib/src/annotation_manager/route_map_layer_manager.dart index 0578540..5912617 100644 --- a/lib/src/annotation_manager/route_map_layer_manager.dart +++ b/lib/src/annotation_manager/route_map_layer_manager.dart @@ -60,6 +60,7 @@ class RouteMapLayerManager { // Mirror maplibre_gl's own default (true) so callers that don't // explicitly opt out keep the same behavior as before. bool enableInteraction = true, + bool isVisible = true, dynamic filter, }) async { if (_layerIds.contains(layerId)) return; @@ -75,6 +76,15 @@ class RouteMapLayerManager { filter: filter, ); _layerIds.add(layerId); + + if (controller.isDisposed) return; + + // MapLibre defaults new layers to visible; only flip when the + // caller wants it hidden right from the start (saves a native + // round-trip in the common case). + if (!isVisible) { + await controller.setLayerVisibility(layerId, false); + } } Future addImage(String id, Uint8List bytes) async { diff --git a/lib/src/poi_layer_extension.dart b/lib/src/poi_layer_extension.dart index 64ca9dc..26c0890 100644 --- a/lib/src/poi_layer_extension.dart +++ b/lib/src/poi_layer_extension.dart @@ -128,6 +128,7 @@ extension _RouteMapPoiLayerState on _RouteMapState { ), belowLayerId: layer.belowLayerId, enableInteraction: category.interactive, + isVisible: layer.initiallyVisible, filter: filter, ); if (!mounted) return; @@ -168,6 +169,7 @@ extension _RouteMapPoiLayerState on _RouteMapState { circleStrokeWidth: clusterTheme.circleStrokeWidth, ), belowLayerId: layer.belowLayerId, + isVisible: layer.initiallyVisible, filter: ['has', 'point_count'], ); if (!mounted) return; @@ -182,6 +184,7 @@ extension _RouteMapPoiLayerState on _RouteMapState { textColor: textColor.toHexStringRGB(), ), belowLayerId: layer.belowLayerId, + isVisible: layer.initiallyVisible, filter: ['has', 'point_count'], ); if (!mounted) return; @@ -198,14 +201,6 @@ extension _RouteMapPoiLayerState on _RouteMapState { isVisible: layer.initiallyVisible, ); _poiLayers[layer.identifier] = entry; - - if (!layer.initiallyVisible) { - await _applyPoiLayerVisibility( - controller: controller, - entry: entry, - isVisible: false, - ); - } } Future _applyPoiLayerVisibility({ From 4fa5e8affbbf31143a8bf962e6f10397e897525e Mon Sep 17 00:00:00 2001 From: Julian Bissekkou Date: Fri, 3 Jul 2026 13:30:38 +0200 Subject: [PATCH 09/16] nit fix --- lib/src/route_map_base.dart | 2 +- test/coalescing_runner_test.dart | 243 +++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 test/coalescing_runner_test.dart diff --git a/lib/src/route_map_base.dart b/lib/src/route_map_base.dart index b8eb36a..4822695 100644 --- a/lib/src/route_map_base.dart +++ b/lib/src/route_map_base.dart @@ -104,7 +104,7 @@ class _RouteMapState extends State { /// mid-install into a single trailing rerun — enough to pick up /// whatever caused the latest fire without executing every fire /// individually. - late final CoalescingRunner _installRunner = CoalescingRunner( + late final _installRunner = CoalescingRunner( _installStyleContent, shouldRerun: () => mounted, ); diff --git a/test/coalescing_runner_test.dart b/test/coalescing_runner_test.dart new file mode 100644 index 0000000..62f9b53 --- /dev/null +++ b/test/coalescing_runner_test.dart @@ -0,0 +1,243 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:route_map/src/utils/coalescing_runner.dart'; + +void main() { + group('CoalescingRunner', () { + test('runs the task immediately when idle', () async { + var calls = 0; + final runner = CoalescingRunner(() async => calls++); + + await runner.schedule(); + + expect(calls, 1); + }); + + test('isRunning is true while in flight and false once finished', () async { + final gate = Completer(); + late final CoalescingRunner runner; + runner = CoalescingRunner(() => gate.future); + + expect(runner.isRunning, isFalse); + + final first = runner.schedule(); + expect(runner.isRunning, isTrue); + + gate.complete(); + await first; + expect(runner.isRunning, isFalse); + }); + + test('each schedule runs again once the runner is idle', () async { + var calls = 0; + final runner = CoalescingRunner(() async => calls++); + + await runner.schedule(); + await runner.schedule(); + + expect(calls, 2); + }); + + test('coalesces overlapping schedules into a single rerun', () async { + var calls = 0; + final gates = >[]; + late final CoalescingRunner runner; + runner = CoalescingRunner(() { + calls++; + final gate = Completer(); + gates.add(gate); + return gate.future; + }); + + // Start run #1. + final first = runner.schedule(); + expect(calls, 1); + + // Several fires arrive while run #1 is still in flight. + unawaited(runner.schedule()); + unawaited(runner.schedule()); + unawaited(runner.schedule()); + // None of them start a concurrent run. + expect(calls, 1); + + // Finishing run #1 folds all pending fires into exactly one rerun. + gates[0].complete(); + await pumpEventQueue(); + expect(calls, 2); + + // Finishing the rerun leaves no further work. + gates[1].complete(); + await first; + expect(calls, 2); + }); + + test('never runs two tasks concurrently', () async { + var active = 0; + var maxActive = 0; + final gates = >[]; + late final CoalescingRunner runner; + runner = CoalescingRunner(() { + active++; + maxActive = active > maxActive ? active : maxActive; + final gate = Completer(); + gates.add(gate); + return gate.future.whenComplete(() => active--); + }); + + final first = runner.schedule(); + unawaited(runner.schedule()); + unawaited(runner.schedule()); + + gates[0].complete(); + await pumpEventQueue(); + gates[1].complete(); + await first; + + expect(maxActive, 1); + }); + + test('the coalesced rerun observes the latest external state', () async { + final observed = []; + var external = 0; + final gates = >[]; + late final CoalescingRunner runner; + runner = CoalescingRunner(() { + observed.add(external); + final gate = Completer(); + gates.add(gate); + return gate.future; + }); + + external = 1; + final first = runner.schedule(); // run #1 observes 1 + external = 2; + unawaited(runner.schedule()); // requests a rerun + external = 3; // state keeps changing before run #1 completes + + gates[0].complete(); // rerun starts and should observe the newest value + await pumpEventQueue(); + gates[1].complete(); + await first; + + // Only two runs, and the rerun skipped the intermediate value (2). + expect(observed, [1, 3]); + }); + + test('a schedule during the rerun triggers a further rerun', () async { + var calls = 0; + final gates = >[]; + late final CoalescingRunner runner; + runner = CoalescingRunner(() { + calls++; + final gate = Completer(); + gates.add(gate); + return gate.future; + }); + + final first = runner.schedule(); // run #1 + unawaited(runner.schedule()); // -> rerun (run #2) pending + + gates[0].complete(); + await pumpEventQueue(); + expect(calls, 2); // run #2 in flight + + unawaited(runner.schedule()); // -> rerun (run #3) pending + gates[1].complete(); + await pumpEventQueue(); + expect(calls, 3); // run #3 in flight + + gates[2].complete(); + await first; + expect(calls, 3); + }); + + test('shouldRerun == false suppresses the pending rerun', () async { + var calls = 0; + final gates = >[]; + late final CoalescingRunner runner; + runner = CoalescingRunner(() { + calls++; + final gate = Completer(); + gates.add(gate); + return gate.future; + }, shouldRerun: () => false); + + final first = runner.schedule(); + unawaited(runner.schedule()); // requests a rerun that must be aborted + + gates[0].complete(); + await first; + + // Initial run still happened; the rerun was suppressed by the guard. + expect(calls, 1); + }); + + test('shouldRerun == true allows the pending rerun', () async { + var calls = 0; + final gates = >[]; + late final CoalescingRunner runner; + runner = CoalescingRunner(() { + calls++; + final gate = Completer(); + gates.add(gate); + return gate.future; + }, shouldRerun: () => true); + + final first = runner.schedule(); + unawaited(runner.schedule()); + + gates[0].complete(); + await pumpEventQueue(); + expect(calls, 2); + + gates[1].complete(); + await first; + expect(calls, 2); + }); + + test('shouldRerun is only consulted for reruns, not the first run', () async { + var calls = 0; + var shouldRerunCallCount = 0; + final runner = CoalescingRunner(() async { + calls++; + }, shouldRerun: () { + shouldRerunCallCount++; + return true; + }); + + // A lone schedule finishes before any other fire arrives, so there is + // no pending rerun and the guard is never consulted. + await runner.schedule(); + + expect(calls, 1); + expect(shouldRerunCallCount, 0); + }); + + test('recovers after the task throws so it can run again', () async { + var calls = 0; + var shouldThrow = true; + final runner = CoalescingRunner(() async { + calls++; + if (shouldThrow) throw StateError('boom'); + }); + + // Swallow the failure so the test can assert on recovery. (The `finally` + // block is what guarantees the runner is left in a usable state.) + await runner.schedule().catchError((Object _) {}); + expect( + runner.isRunning, + isFalse, + reason: 'the finally block must reset _isRunning even on error', + ); + + shouldThrow = false; + await runner.schedule(); + expect( + calls, + 2, + reason: 'the runner must be schedulable again after a failed run', + ); + }); + }); +} From 47ee0c4b783fd652392a6b136fd8a362e357a211 Mon Sep 17 00:00:00 2001 From: Julian Bissekkou Date: Fri, 3 Jul 2026 13:48:53 +0200 Subject: [PATCH 10/16] fix: introduce _mapStyleLoadLock to fix access of managers when concurrent onStyleLoadedCallback --- example/pubspec.lock | 16 ++++++++++++---- lib/src/route_map_base.dart | 23 ++++++++++++++++++----- pubspec.yaml | 1 + 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/example/pubspec.lock b/example/pubspec.lock index 88b10fb..52e4437 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -204,10 +204,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -300,6 +300,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + url: "https://pub.dev" + source: hosted + version: "3.4.0+1" tapped_lints: dependency: "direct dev" description: @@ -321,10 +329,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.9" typed_data: dependency: transitive description: diff --git a/lib/src/route_map_base.dart b/lib/src/route_map_base.dart index 4822695..063e530 100644 --- a/lib/src/route_map_base.dart +++ b/lib/src/route_map_base.dart @@ -14,6 +14,7 @@ import 'package:route_map/src/annotation_manager/route_map_line_manager.dart'; import 'package:route_map/src/route_map_camera_update.dart'; import 'package:route_map/src/route_map_geometry_extension.dart'; import 'package:route_map/src/utils/coalescing_runner.dart'; +import 'package:synchronized/synchronized.dart'; part 'route_map_controller.dart'; @@ -95,6 +96,8 @@ class _RouteMapState extends State { bool _wasCameraMoving = false; VoidCallback? _cameraStateListener; + final _mapStyleLoadLock = Lock(); + /// Coordinates concurrent `onStyleLoadedCallback` invocations. /// MapLibre fires the callback on initial construction *and* on /// every subsequent style change (theme switch, style-url reload), @@ -127,23 +130,23 @@ class _RouteMapState extends State { Future get _iconManager async { // wait for style and restore to complete - await _fullyLoadedCompleter.future; + await _afterStyleReady(); return _iconManagerInstance; } Future get _lineManager async { - await _fullyLoadedCompleter.future; + await _afterStyleReady(); return _lineManagerInstance; } Future get _circleManager async { - await _fullyLoadedCompleter.future; + await _afterStyleReady(); return _circleManagerInstance; } Future get _locationIndicatorCoordinator async { - await _fullyLoadedCompleter.future; + await _afterStyleReady(); return _locationIndicatorCoordinatorInstance; } @@ -294,7 +297,12 @@ class _RouteMapState extends State { controller.onFeatureHover.add(_onFeatureHover); } }, - onStyleLoadedCallback: _installRunner.schedule, + onStyleLoadedCallback: () => + // We might run into the issue that we have concurrent calls of onStyleLoadedCallback. + // _installRunner makes sure that we only run one at a time and coalesce overlapping calls. + // However, our managers are not safe and should only be used after all concurrent calls have finished. + // Therefore, we use a lock which is awaited in [_afterStyleReady]. + _mapStyleLoadLock.synchronized(_installRunner.schedule), annotationOrder: const [ AnnotationType.fill, AnnotationType.line, @@ -354,4 +362,9 @@ class _RouteMapState extends State { final controller = await _controller; await controller.setSymbolIconAllowOverlap(widget.allowIconsOverlap); } + + Future _afterStyleReady() async { + await _fullyLoadedCompleter.future; + await _mapStyleLoadLock.synchronized(() async {}); + } } diff --git a/pubspec.yaml b/pubspec.yaml index b4af16f..993f01a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: freezed_annotation: ^3.1.0 collection: ^1.19.1 maplibre_gl: ^0.26.2 + synchronized: ^3.4.0 dev_dependencies: flutter_test: From fe80341f4c979f7f6a2a601cc425648ddcad263e Mon Sep 17 00:00:00 2001 From: Julian Bissekkou Date: Fri, 3 Jul 2026 13:53:30 +0200 Subject: [PATCH 11/16] fix: remove cocoapod integration in example app --- example/ios/Flutter/Debug.xcconfig | 1 - example/ios/Podfile | 43 ---------- example/ios/Podfile.lock | 16 ---- example/ios/Runner.xcodeproj/project.pbxproj | 86 -------------------- 4 files changed, 146 deletions(-) delete mode 100644 example/ios/Podfile delete mode 100644 example/ios/Podfile.lock diff --git a/example/ios/Flutter/Debug.xcconfig b/example/ios/Flutter/Debug.xcconfig index ec97fc6..592ceee 100644 --- a/example/ios/Flutter/Debug.xcconfig +++ b/example/ios/Flutter/Debug.xcconfig @@ -1,2 +1 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/example/ios/Podfile b/example/ios/Podfile deleted file mode 100644 index 620e46e..0000000 --- a/example/ios/Podfile +++ /dev/null @@ -1,43 +0,0 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '13.0' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_ios_podfile_setup - -target 'Runner' do - use_frameworks! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_ios_build_settings(target) - end -end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock deleted file mode 100644 index 64cf43c..0000000 --- a/example/ios/Podfile.lock +++ /dev/null @@ -1,16 +0,0 @@ -PODS: - - Flutter (1.0.0) - -DEPENDENCIES: - - Flutter (from `Flutter`) - -EXTERNAL SOURCES: - Flutter: - :path: Flutter - -SPEC CHECKSUMS: - Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - -PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e - -COCOAPODS: 1.16.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 4e42195..de1643c 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -8,10 +8,8 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 3182DF1E432001177B56077C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62CB108EF28B741B8F0073D9 /* Pods_Runner.framework */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 426ECBF09EC6E238B98178AC /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AC72F9D2C9263D1FCB9E2E80 /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -48,14 +46,10 @@ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 562596180969129ED5E45DD7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - 5F9B05981671EDFA50539376 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - 62CB108EF28B741B8F0073D9 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 821CA411C495F1A407E8E1B9 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -63,10 +57,6 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - AC72F9D2C9263D1FCB9E2E80 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - C20750E8F2147DED185A1041 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - CF2B1EEE0718883AD408DF4B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - E67732F35B5CCFC5703F396A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -74,7 +64,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 426ECBF09EC6E238B98178AC /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -83,22 +72,12 @@ buildActionMask = 2147483647; files = ( 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, - 3182DF1E432001177B56077C /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 09C98E31C6F8877A54C6DDCC /* Frameworks */ = { - isa = PBXGroup; - children = ( - 62CB108EF28B741B8F0073D9 /* Pods_Runner.framework */, - AC72F9D2C9263D1FCB9E2E80 /* Pods_RunnerTests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -107,20 +86,6 @@ path = RunnerTests; sourceTree = ""; }; - 41E29D77C606FC9707E7107A /* Pods */ = { - isa = PBXGroup; - children = ( - CF2B1EEE0718883AD408DF4B /* Pods-Runner.debug.xcconfig */, - 5F9B05981671EDFA50539376 /* Pods-Runner.release.xcconfig */, - C20750E8F2147DED185A1041 /* Pods-Runner.profile.xcconfig */, - 821CA411C495F1A407E8E1B9 /* Pods-RunnerTests.debug.xcconfig */, - 562596180969129ED5E45DD7 /* Pods-RunnerTests.release.xcconfig */, - E67732F35B5CCFC5703F396A /* Pods-RunnerTests.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -140,8 +105,6 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, - 41E29D77C606FC9707E7107A /* Pods */, - 09C98E31C6F8877A54C6DDCC /* Frameworks */, ); sourceTree = ""; }; @@ -176,7 +139,6 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 39D4A2E1CEB51C535F095DB2 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, 140B8BDF5808B31CCC475F07 /* Frameworks */, @@ -195,7 +157,6 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 33F64D51EA5BD1849C820844 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -279,50 +240,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 33F64D51EA5BD1849C820844 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 39D4A2E1CEB51C535F095DB2 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -480,7 +397,6 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 821CA411C495F1A407E8E1B9 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -498,7 +414,6 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 562596180969129ED5E45DD7 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -514,7 +429,6 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = E67732F35B5CCFC5703F396A /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; From b002b3abeaed23c68b8e7d646f96e450714f1f99 Mon Sep 17 00:00:00 2001 From: Julian Bissekkou Date: Fri, 3 Jul 2026 13:58:24 +0200 Subject: [PATCH 12/16] fix: cleanup example app --- example/lib/examples/map_bottom_sheet.dart | 3 -- .../lib/examples/route_and_icons_example.dart | 1 - example/lib/main.dart | 2 +- example/lib/util/intercept_platform_view.dart | 49 +++++++++++++++++++ example/pubspec.lock | 8 +++ example/pubspec.yaml | 1 + 6 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 example/lib/util/intercept_platform_view.dart diff --git a/example/lib/examples/map_bottom_sheet.dart b/example/lib/examples/map_bottom_sheet.dart index eb36395..24bf8f7 100644 --- a/example/lib/examples/map_bottom_sheet.dart +++ b/example/lib/examples/map_bottom_sheet.dart @@ -96,7 +96,6 @@ class _MapBottomSheetPageState extends State { Future _openSheet() { return showModalBottomSheet( context: context, - isScrollControlled: true, useSafeArea: true, builder: (_) => const _DetailedMapSheet(), ); @@ -118,7 +117,6 @@ class _MapBottomSheetPageState extends State { child: SizedBox( height: 240, child: RouteMap( - key: const Key("small map - map_bottom_sheet"), styleUrl: styleUrl, locale: "en", zoomPadding: const EdgeInsets.all(24), @@ -197,7 +195,6 @@ class _DetailedMapSheetState extends State<_DetailedMapSheet> { ), Expanded( child: RouteMap( - key: const Key("map_bottom_sheet"), styleUrl: _styleUrl, locale: "en", zoomPadding: const EdgeInsets.all(40), diff --git a/example/lib/examples/route_and_icons_example.dart b/example/lib/examples/route_and_icons_example.dart index 13773b5..fc5ad97 100644 --- a/example/lib/examples/route_and_icons_example.dart +++ b/example/lib/examples/route_and_icons_example.dart @@ -87,7 +87,6 @@ class _RouteAndIconsExampleState extends State { return Stack( children: [ RouteMap( - key: const Key("route and icons example - route_map"), minMaxZoomPreference: const MinMaxZoomPreference(5, 18), styleUrl: styleUrl, locale: "en", diff --git a/example/lib/main.dart b/example/lib/main.dart index 988d8cf..27506d4 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -22,7 +22,7 @@ class MyApp extends StatelessWidget { ), darkTheme: ThemeData( colorScheme: ColorScheme.fromSeed( - seedColor: Colors.deepPurple, + seedColor: Colors.blue, brightness: Brightness.dark, ), ), diff --git a/example/lib/util/intercept_platform_view.dart b/example/lib/util/intercept_platform_view.dart new file mode 100644 index 0000000..d770bbc --- /dev/null +++ b/example/lib/util/intercept_platform_view.dart @@ -0,0 +1,49 @@ +import 'package:flutter/cupertino.dart'; +import 'package:universal_platform/universal_platform.dart'; + +/// Inspirited of: https://pub.dev/packages/pointer_interceptor +/// +/// 😵‍💫 Whats the problem? +/// When overlaying Flutter widgets on top of HtmlElementView/PlatformView widgets +/// that respond to mouse gestures (handle clicks, for example), +/// the clicks will be consumed by the HtmlElementView/PlatformView, +/// and not relayed to Flutter. +/// The result is that Flutter widget's onTap (and other) handlers won't fire as expected, +/// but they'll affect the underlying native platform view. +/// +/// 🔎 Where do we have an problem? +/// We had issues that the [VectorMap] received events from the Flutter widgets above. +class InterceptPlatformView extends StatelessWidget { + final Widget child; + + final bool intercepting; + + const InterceptPlatformView({ + this.intercepting = true, + required this.child, + super.key, + }); + + @override + Widget build(BuildContext context) { + if (UniversalPlatform.isWeb) { + return Stack( + alignment: Alignment.center, + children: [ + if (intercepting) + // We need to add the HtmlElementView
to prevent events are captured by an underlying HtmlElementView in web. + ExcludeFocus( + child: Positioned.fill( + child: HtmlElementView.fromTagName( + tagName: 'div', + isVisible: false, + ), + ), + ), + child, + ], + ); + } + return child; + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index 52e4437..1d2a56b 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -341,6 +341,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_platform: + dependency: "direct main" + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" vector_graphics: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 9cbe387..092b6b3 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: sdk: flutter route_map: path: ../ + universal_platform: ^1.1.0 dev_dependencies: flutter_test: From 0413ebbe8e4fbd5c15bb736c3fd072f109362566 Mon Sep 17 00:00:00 2001 From: Julian Bissekkou Date: Fri, 3 Jul 2026 14:10:01 +0200 Subject: [PATCH 13/16] add navigation rail --- example/lib/examples/map_bottom_sheet.dart | 94 +------------------ .../lib/examples/route_and_icons_example.dart | 11 +-- example/lib/main.dart | 70 +++++--------- example/lib/util/example_service_area.dart | 69 ++++++++++++++ example/lib/util/intercept_platform_view.dart | 49 ---------- example/lib/util/style_url.dart | 9 ++ example/pubspec.lock | 8 -- example/pubspec.yaml | 1 - 8 files changed, 111 insertions(+), 200 deletions(-) create mode 100644 example/lib/util/example_service_area.dart delete mode 100644 example/lib/util/intercept_platform_view.dart create mode 100644 example/lib/util/style_url.dart diff --git a/example/lib/examples/map_bottom_sheet.dart b/example/lib/examples/map_bottom_sheet.dart index 24bf8f7..3639c3a 100644 --- a/example/lib/examples/map_bottom_sheet.dart +++ b/example/lib/examples/map_bottom_sheet.dart @@ -1,88 +1,8 @@ +import 'package:example/util/example_service_area.dart'; +import 'package:example/util/style_url.dart'; import 'package:flutter/material.dart'; import 'package:route_map/route_map.dart'; -/// A small map plus a button that opens a draggable bottom sheet containing a -/// more detailed map. Both maps render the same (fairly complex) service -/// area so the layer setup runs on every style load. -/// -/// Dragging / resizing the sheet embeds the map in an animated container and -/// the refresh button reloads the style at runtime — both re-fire -/// `onStyleLoadedCallback`, which is what surfaces issue #13 -/// (`sourceAlreadyExists` for `service_area_source_id_0`). -const _lightStyle = - "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; -const _darkStyle = - "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"; - -/// A more complex service area: a multi-polygon (two separate areas) where the -/// first polygon also has a hole. -const Map serviceAreaGeoJson = { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "properties": {}, - "geometry": { - "type": "MultiPolygon", - "coordinates": [ - [ - // Polygon 1 — irregular outer ring ... - [ - [11.35, 48.00], - [11.50, 47.97], - [11.66, 48.02], - [11.74, 48.12], - [11.70, 48.24], - [11.58, 48.30], - [11.45, 48.27], - [11.36, 48.18], - [11.30, 48.09], - [11.35, 48.00], - ], - // ... with a hole in the middle. - [ - [11.50, 48.10], - [11.60, 48.10], - [11.62, 48.17], - [11.53, 48.19], - [11.48, 48.15], - [11.50, 48.10], - ], - ], - [ - // Polygon 2 — a separate area to the east. - [ - [11.82, 48.02], - [11.95, 48.05], - [11.98, 48.14], - [11.90, 48.20], - [11.80, 48.13], - [11.82, 48.02], - ], - ], - ], - }, - }, - ], -}; - -Future createServiceAreaSource() async { - return const GeojsonSourceProperties(data: serviceAreaGeoJson); -} - -List serviceAreaLayers() => [ - ServiceAreaLayer( - createSource: createServiceAreaSource, - fillColor: Colors.grey.withValues(alpha: 0.45), - hashLines: const ServiceAreaHashLines(color: Colors.black26, spacing: 10), - border: const ServiceAreaBorder( - createSource: createServiceAreaSource, - color: Colors.redAccent, - width: 2, - ), - ), -]; - class MapBottomSheetPage extends StatefulWidget { const MapBottomSheetPage({super.key}); @@ -103,10 +23,6 @@ class _MapBottomSheetPageState extends State { @override Widget build(BuildContext context) { - final isLightTheme = Theme.of(context).brightness == Brightness.light; - - final styleUrl = isLightTheme ? _lightStyle : _darkStyle; - return Padding( padding: const EdgeInsets.all(16), child: Column( @@ -117,7 +33,7 @@ class _MapBottomSheetPageState extends State { child: SizedBox( height: 240, child: RouteMap( - styleUrl: styleUrl, + styleUrl: getStyleUrl(context), locale: "en", zoomPadding: const EdgeInsets.all(24), controller: _smallMapController, @@ -153,9 +69,7 @@ class _DetailedMapSheet extends StatefulWidget { class _DetailedMapSheetState extends State<_DetailedMapSheet> { final _controller = RouteMapController(); - double _heightFactor = 0.6; - final String _styleUrl = _lightStyle; @override Widget build(BuildContext context) { @@ -195,7 +109,7 @@ class _DetailedMapSheetState extends State<_DetailedMapSheet> { ), Expanded( child: RouteMap( - styleUrl: _styleUrl, + styleUrl: getStyleUrl(context), locale: "en", zoomPadding: const EdgeInsets.all(40), controller: _controller, diff --git a/example/lib/examples/route_and_icons_example.dart b/example/lib/examples/route_and_icons_example.dart index fc5ad97..054d1e4 100644 --- a/example/lib/examples/route_and_icons_example.dart +++ b/example/lib/examples/route_and_icons_example.dart @@ -1,4 +1,5 @@ -import 'package:example/examples/map_bottom_sheet.dart'; +import 'package:example/util/example_service_area.dart'; +import 'package:example/util/style_url.dart'; import 'package:flutter/material.dart'; import 'package:route_map/route_map.dart'; @@ -78,17 +79,11 @@ class _RouteAndIconsExampleState extends State { @override Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - - final styleUrl = isDarkMode - ? "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json" - : "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; - return Stack( children: [ RouteMap( minMaxZoomPreference: const MinMaxZoomPreference(5, 18), - styleUrl: styleUrl, + styleUrl: getStyleUrl(context), locale: "en", zoomPadding: const EdgeInsets.only( left: 40, diff --git a/example/lib/main.dart b/example/lib/main.dart index 27506d4..29913be 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -4,11 +4,11 @@ import 'package:example/examples/map_bottom_sheet.dart'; import 'package:example/examples/route_and_icons_example.dart'; void main() { - runApp(const MyApp()); + runApp(const ExampleApp()); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class ExampleApp extends StatelessWidget { + const ExampleApp({super.key}); @override Widget build(BuildContext context) { @@ -32,7 +32,7 @@ class MyApp extends StatelessWidget { } } -/// A single selectable example in the drawer. +/// A single selectable example in the navigation rail. class ExamplePage { final String title; final String subtitle; @@ -62,7 +62,7 @@ final _examples = [ ), ]; -/// App shell hosting the navigation drawer and the currently-selected example. +/// App shell hosting the navigation rail and the currently-selected example. class HomeShell extends StatefulWidget { const HomeShell({super.key}); @@ -73,10 +73,7 @@ class HomeShell extends StatefulWidget { class _HomeShellState extends State { int _selectedIndex = 0; - void _select(int index) { - setState(() => _selectedIndex = index); - Navigator.of(context).pop(); // close the drawer - } + void _select(int index) => setState(() => _selectedIndex = index); @override Widget build(BuildContext context) { @@ -84,44 +81,29 @@ class _HomeShellState extends State { return Scaffold( appBar: AppBar(title: Text(example.title)), - drawer: Drawer( - child: SafeArea( - child: Column( - children: [ - const DrawerHeader( - child: Align( - alignment: Alignment.bottomLeft, - child: Text( - "route_map examples", - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - ), - ), - Expanded( - child: ListView.builder( - padding: EdgeInsets.zero, - itemCount: _examples.length, - itemBuilder: (context, index) { - final e = _examples[index]; - return ListTile( - leading: Icon(e.icon), - title: Text(e.title), - subtitle: Text(e.subtitle), - selected: index == _selectedIndex, - onTap: () => _select(index), - ); - }, + body: Row( + children: [ + NavigationRail( + labelType: NavigationRailLabelType.all, + selectedIndex: _selectedIndex, + onDestinationSelected: _select, + destinations: [ + for (final e in _examples) + NavigationRailDestination( + icon: Icon(e.icon), + selectedIcon: Icon(e.icon), + label: Text(e.title), ), - ), ], ), - ), - ), - // A key per index ensures each example is fully rebuilt (fresh map - // controllers) when switching pages. - body: KeyedSubtree( - key: ValueKey(_selectedIndex), - child: example.builder(context), + const VerticalDivider(width: 1), + Expanded( + child: KeyedSubtree( + key: ValueKey(_selectedIndex), + child: example.builder(context), + ), + ), + ], ), ); } diff --git a/example/lib/util/example_service_area.dart b/example/lib/util/example_service_area.dart new file mode 100644 index 0000000..93e6c2d --- /dev/null +++ b/example/lib/util/example_service_area.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:route_map/route_map.dart'; + +const Map serviceAreaGeoJson = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + // Polygon 1 — irregular outer ring ... + [ + [11.35, 48.00], + [11.50, 47.97], + [11.66, 48.02], + [11.74, 48.12], + [11.70, 48.24], + [11.58, 48.30], + [11.45, 48.27], + [11.36, 48.18], + [11.30, 48.09], + [11.35, 48.00], + ], + // ... with a hole in the middle. + [ + [11.50, 48.10], + [11.60, 48.10], + [11.62, 48.17], + [11.53, 48.19], + [11.48, 48.15], + [11.50, 48.10], + ], + ], + [ + // Polygon 2 — a separate area to the east. + [ + [11.82, 48.02], + [11.95, 48.05], + [11.98, 48.14], + [11.90, 48.20], + [11.80, 48.13], + [11.82, 48.02], + ], + ], + ], + }, + }, + ], +}; + +Future createServiceAreaSource() async { + return const GeojsonSourceProperties(data: serviceAreaGeoJson); +} + +List serviceAreaLayers() => [ + ServiceAreaLayer( + createSource: createServiceAreaSource, + fillColor: Colors.grey.withValues(alpha: 0.45), + hashLines: const ServiceAreaHashLines(color: Colors.black26, spacing: 10), + border: const ServiceAreaBorder( + createSource: createServiceAreaSource, + color: Colors.redAccent, + width: 2, + ), + ), +]; \ No newline at end of file diff --git a/example/lib/util/intercept_platform_view.dart b/example/lib/util/intercept_platform_view.dart deleted file mode 100644 index d770bbc..0000000 --- a/example/lib/util/intercept_platform_view.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:universal_platform/universal_platform.dart'; - -/// Inspirited of: https://pub.dev/packages/pointer_interceptor -/// -/// 😵‍💫 Whats the problem? -/// When overlaying Flutter widgets on top of HtmlElementView/PlatformView widgets -/// that respond to mouse gestures (handle clicks, for example), -/// the clicks will be consumed by the HtmlElementView/PlatformView, -/// and not relayed to Flutter. -/// The result is that Flutter widget's onTap (and other) handlers won't fire as expected, -/// but they'll affect the underlying native platform view. -/// -/// 🔎 Where do we have an problem? -/// We had issues that the [VectorMap] received events from the Flutter widgets above. -class InterceptPlatformView extends StatelessWidget { - final Widget child; - - final bool intercepting; - - const InterceptPlatformView({ - this.intercepting = true, - required this.child, - super.key, - }); - - @override - Widget build(BuildContext context) { - if (UniversalPlatform.isWeb) { - return Stack( - alignment: Alignment.center, - children: [ - if (intercepting) - // We need to add the HtmlElementView
to prevent events are captured by an underlying HtmlElementView in web. - ExcludeFocus( - child: Positioned.fill( - child: HtmlElementView.fromTagName( - tagName: 'div', - isVisible: false, - ), - ), - ), - child, - ], - ); - } - return child; - } -} diff --git a/example/lib/util/style_url.dart b/example/lib/util/style_url.dart new file mode 100644 index 0000000..0c6aa1b --- /dev/null +++ b/example/lib/util/style_url.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; + +String getStyleUrl(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return isDarkMode + ? "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json" + : "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; +} diff --git a/example/pubspec.lock b/example/pubspec.lock index 1d2a56b..52e4437 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -341,14 +341,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - universal_platform: - dependency: "direct main" - description: - name: universal_platform - sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" - url: "https://pub.dev" - source: hosted - version: "1.1.0" vector_graphics: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 092b6b3..9cbe387 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -11,7 +11,6 @@ dependencies: sdk: flutter route_map: path: ../ - universal_platform: ^1.1.0 dev_dependencies: flutter_test: From 0126acdc3e4fa9d9ea56957db46cb6aa2477b8b9 Mon Sep 17 00:00:00 2001 From: Julian Bissekkou Date: Fri, 3 Jul 2026 14:17:47 +0200 Subject: [PATCH 14/16] cleanup main.dart --- example/lib/main.dart | 149 +++++++++++++++++++++++++++++------------- 1 file changed, 102 insertions(+), 47 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 29913be..dde73f8 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -32,7 +32,107 @@ class ExampleApp extends StatelessWidget { } } -/// A single selectable example in the navigation rail. +class HomeShell extends StatefulWidget { + const HomeShell({super.key}); + + @override + State createState() => _HomeShellState(); +} + +class _HomeShellState extends State { + static const _mobileBreakpoint = 600.0; + + int _selectedIndex = 0; + + void _select(int index) => setState(() => _selectedIndex = index); + + void _selectFromDrawer(int index) { + _select(index); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final example = _examples[_selectedIndex]; + final isMobile = MediaQuery.sizeOf(context).width < _mobileBreakpoint; + + return Scaffold( + appBar: AppBar(title: Text(example.title)), + drawer: isMobile ? _buildDrawer() : null, + body: isMobile ? _buildSelectedExample() : _buildRailLayout(), + ); + } + + Widget _buildRailLayout() { + return Row( + children: [ + NavigationRail( + labelType: NavigationRailLabelType.all, + selectedIndex: _selectedIndex, + onDestinationSelected: _select, + destinations: [ + for (final e in _examples) + NavigationRailDestination( + icon: Icon(e.icon), + selectedIcon: Icon(e.icon), + label: Text(e.title), + ), + ], + ), + const VerticalDivider(width: 1), + Expanded(child: _buildSelectedExample()), + ], + ); + } + + Widget _buildDrawer() { + return Drawer( + child: SafeArea( + child: Column( + children: [ + const DrawerHeader( + child: Align( + alignment: Alignment.bottomLeft, + child: Text( + "route_map examples", + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + ), + ), + Expanded( + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: _examples.length, + itemBuilder: (context, index) { + final example = _examples[index]; + return ListTile( + leading: Icon(example.icon), + title: Text(example.title), + subtitle: Text(example.subtitle), + selected: index == _selectedIndex, + onTap: () => _selectFromDrawer(index), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _buildSelectedExample() { + final example = _examples[_selectedIndex]; + + return KeyedSubtree( + key: ValueKey(_selectedIndex), + child: example.builder(context), + ); + } +} + +// region example pages + class ExamplePage { final String title; final String subtitle; @@ -62,49 +162,4 @@ final _examples = [ ), ]; -/// App shell hosting the navigation rail and the currently-selected example. -class HomeShell extends StatefulWidget { - const HomeShell({super.key}); - - @override - State createState() => _HomeShellState(); -} - -class _HomeShellState extends State { - int _selectedIndex = 0; - - void _select(int index) => setState(() => _selectedIndex = index); - - @override - Widget build(BuildContext context) { - final example = _examples[_selectedIndex]; - - return Scaffold( - appBar: AppBar(title: Text(example.title)), - body: Row( - children: [ - NavigationRail( - labelType: NavigationRailLabelType.all, - selectedIndex: _selectedIndex, - onDestinationSelected: _select, - destinations: [ - for (final e in _examples) - NavigationRailDestination( - icon: Icon(e.icon), - selectedIcon: Icon(e.icon), - label: Text(e.title), - ), - ], - ), - const VerticalDivider(width: 1), - Expanded( - child: KeyedSubtree( - key: ValueKey(_selectedIndex), - child: example.builder(context), - ), - ), - ], - ), - ); - } -} +// endregion From 9cfb16c30d50c7427152b53a65ea9d57bd06f87b Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 3 Jul 2026 14:32:40 +0200 Subject: [PATCH 15/16] fix layer visisbility --- example/lib/util/example_service_area.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/lib/util/example_service_area.dart b/example/lib/util/example_service_area.dart index 93e6c2d..4ddb595 100644 --- a/example/lib/util/example_service_area.dart +++ b/example/lib/util/example_service_area.dart @@ -66,4 +66,4 @@ List serviceAreaLayers() => [ width: 2, ), ), -]; \ No newline at end of file +]; From 693c48d9bb7b26e85ff0ec540258ed40bad83a58 Mon Sep 17 00:00:00 2001 From: Stefan Schaller Date: Fri, 3 Jul 2026 14:37:11 +0200 Subject: [PATCH 16/16] fix layer visisbility --- test/coalescing_runner_test.dart | 40 ++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/test/coalescing_runner_test.dart b/test/coalescing_runner_test.dart index 62f9b53..535cc32 100644 --- a/test/coalescing_runner_test.dart +++ b/test/coalescing_runner_test.dart @@ -196,23 +196,29 @@ void main() { expect(calls, 2); }); - test('shouldRerun is only consulted for reruns, not the first run', () async { - var calls = 0; - var shouldRerunCallCount = 0; - final runner = CoalescingRunner(() async { - calls++; - }, shouldRerun: () { - shouldRerunCallCount++; - return true; - }); - - // A lone schedule finishes before any other fire arrives, so there is - // no pending rerun and the guard is never consulted. - await runner.schedule(); - - expect(calls, 1); - expect(shouldRerunCallCount, 0); - }); + test( + 'shouldRerun is only consulted for reruns, not the first run', + () async { + var calls = 0; + var shouldRerunCallCount = 0; + final runner = CoalescingRunner( + () async { + calls++; + }, + shouldRerun: () { + shouldRerunCallCount++; + return true; + }, + ); + + // A lone schedule finishes before any other fire arrives, so there is + // no pending rerun and the guard is never consulted. + await runner.schedule(); + + expect(calls, 1); + expect(shouldRerunCallCount, 0); + }, + ); test('recovers after the task throws so it can run again', () async { var calls = 0;