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; 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..3639c3a --- /dev/null +++ b/example/lib/examples/map_bottom_sheet.dart @@ -0,0 +1,130 @@ +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'; + +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, + 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: getStyleUrl(context), + locale: "en", + zoomPadding: const EdgeInsets.all(24), + controller: _smallMapController, + initialCameraPosition: const CameraPosition( + target: LatLng(48.13, 11.62), + zoom: 7.5, + ), + serviceAreaLayers: serviceAreaLayers(), + 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; + + @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), + ), + ), + ], + ), + ), + ), + Expanded( + child: RouteMap( + styleUrl: getStyleUrl(context), + locale: "en", + zoomPadding: const EdgeInsets.all(40), + controller: _controller, + initialCameraPosition: const CameraPosition( + target: LatLng(48.13, 11.62), + zoom: 8.5, + ), + serviceAreaLayers: serviceAreaLayers(), + 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..054d1e4 --- /dev/null +++ b/example/lib/examples/route_and_icons_example.dart @@ -0,0 +1,115 @@ +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'; + +/// 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) { + return Stack( + children: [ + RouteMap( + minMaxZoomPreference: const MinMaxZoomPreference(5, 18), + styleUrl: getStyleUrl(context), + locale: "en", + zoomPadding: const EdgeInsets.only( + left: 40, + right: 40, + top: 60, + bottom: 100, + ), + allowIconsOverlap: true, + ignoreIconsPlacement: true, + controller: _mapController, + serviceAreaLayers: serviceAreaLayers(), + 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..dde73f8 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,12 +1,14 @@ 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()); + runApp(const ExampleApp()); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class ExampleApp extends StatelessWidget { + const ExampleApp({super.key}); @override Widget build(BuildContext context) { @@ -20,124 +22,144 @@ class MyApp extends StatelessWidget { ), darkTheme: ThemeData( colorScheme: ColorScheme.fromSeed( - seedColor: Colors.deepPurple, + seedColor: Colors.blue, brightness: Brightness.dark, ), ), themeMode: ThemeMode.system, - home: const MyHomePage(), + home: const HomeShell(), ); } } -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key}); +class HomeShell extends StatefulWidget { + const HomeShell({super.key}); @override - State createState() => _MyHomePageState(); + State createState() => _HomeShellState(); } -class _MyHomePageState extends State { - final _mapController = RouteMapController(); +class _HomeShellState extends State { + static const _mobileBreakpoint = 600.0; + + int _selectedIndex = 0; + + void _select(int index) => setState(() => _selectedIndex = index); - Path _getPinPath() { - return Path()..addOval(const Rect.fromLTWH(0, 0, 36, 36)); + void _selectFromDrawer(int index) { + _select(index); + Navigator.of(context).pop(); } - 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, - ), - ), + @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(), ); + } - 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, + 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()), + ], ); + } - 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, + 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), + ); + }, + ), + ), + ], ), ), - animateCamera: true, ); - - if (!mounted) return; } - @override - Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; + Widget _buildSelectedExample() { + final example = _examples[_selectedIndex]; - 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 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, - ), - onMapClicked: (_, location) async {}, - ), + return KeyedSubtree( + key: ValueKey(_selectedIndex), + child: example.builder(context), ); } } + +// region example pages + +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, + }); +} + +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(), + ), +]; + +// endregion diff --git a/example/lib/util/example_service_area.dart b/example/lib/util/example_service_area.dart new file mode 100644 index 0000000..4ddb595 --- /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, + ), + ), +]; 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 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/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 2667a19..c5dee9a 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 [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 /// 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/annotation_manager/route_map_layer_manager.dart b/lib/src/annotation_manager/route_map_layer_manager.dart new file mode 100644 index 0000000..5912617 --- /dev/null +++ b/lib/src/annotation_manager/route_map_layer_manager.dart @@ -0,0 +1,156 @@ +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, + bool isVisible = 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); + + 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 { + 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/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/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 08d1132..26c0890 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,55 +38,23 @@ 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 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. + // 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); final template = RouteMapIcon( @@ -97,21 +66,21 @@ 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; } - // Add a SymbolLayer per category. final categoryLayerIds = []; final categoryById = {}; - final brightness = MediaQuery.platformBrightnessOf(context); 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 @@ -128,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 @@ -140,14 +108,12 @@ extension _RouteMapPoiLayerState on _RouteMapState { ? (labelDef.darkHaloColor ?? labelDef.haloColor) : labelDef.haloColor); - await controller.addLayer( + await _layerManagerInstance.addLayer( 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. iconSize: _iconManagerInstance.iconScale, iconAllowOverlap: widget.allowIconsOverlap, iconIgnorePlacement: widget.ignoreIconsPlacement, @@ -160,8 +126,9 @@ extension _RouteMapPoiLayerState on _RouteMapState { textHaloColor: labelHaloColor?.toHexStringRGB(), textHaloWidth: hasLabel ? labelDef.haloWidth : null, ), - belowLayerId: insertBelowLayer, + belowLayerId: layer.belowLayerId, enableInteraction: category.interactive, + isVisible: layer.initiallyVisible, filter: filter, ); if (!mounted) return; @@ -170,9 +137,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) { @@ -190,7 +157,7 @@ extension _RouteMapPoiLayerState on _RouteMapState { ? (clusterTheme.darkTextColor ?? clusterTheme.textColor) : clusterTheme.textColor; - await controller.addLayer( + await _layerManagerInstance.addLayer( sourceId, circleId, CircleLayerProperties( @@ -201,12 +168,13 @@ extension _RouteMapPoiLayerState on _RouteMapState { circleStrokeColor: circleStrokeColor.toHexStringRGB(), circleStrokeWidth: clusterTheme.circleStrokeWidth, ), - belowLayerId: insertBelowLayer, + belowLayerId: layer.belowLayerId, + isVisible: layer.initiallyVisible, filter: ['has', 'point_count'], ); if (!mounted) return; - await controller.addLayer( + await _layerManagerInstance.addLayer( sourceId, textId, SymbolLayerProperties( @@ -215,7 +183,8 @@ extension _RouteMapPoiLayerState on _RouteMapState { // ignore: deprecated_member_use textColor: textColor.toHexStringRGB(), ), - belowLayerId: insertBelowLayer, + belowLayerId: layer.belowLayerId, + isVisible: layer.initiallyVisible, filter: ['has', 'point_count'], ); if (!mounted) return; @@ -232,34 +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 _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({ diff --git a/lib/src/route_map_base.dart b/lib/src/route_map_base.dart index faaa8f5..063e530 100644 --- a/lib/src/route_map_base.dart +++ b/lib/src/route_map_base.dart @@ -9,12 +9,17 @@ 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'; +import 'package:synchronized/synchronized.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 +30,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 +74,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 +96,30 @@ 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), + /// 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 _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; @@ -107,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; } @@ -252,6 +275,7 @@ class _RouteMapState extends State { _circleManagerInstance = RouteMapCircleManager( controller: controller, ); + _layerManagerInstance = RouteMapLayerManager(controller: controller); _locationIndicatorCoordinatorInstance = RouteMapLocationIndicatorCoordinator( circleManager: _circleManagerInstance, @@ -273,25 +297,12 @@ 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 _scheduleGeometryRedraw(); - - if (!_fullyLoadedCompleter.isCompleted) { - _fullyLoadedCompleter.complete(); - } - }, + 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, @@ -302,13 +313,37 @@ class _RouteMapState extends State { ); } + Future _installStyleContent() async { + if (!mounted) return; + + _poiLayers.clear(); + await _layerManagerInstance.removeAll(); + if (!mounted) return; + + await _addServiceAreaLayers(); + if (!mounted) return; + await _addPoiLayers(); + if (!mounted) return; + + await _setMapLanguage(); + if (!mounted) return; + + unawaited(_setOverlap()); + + await _restoreAllGeometry(); + + if (!_fullyLoadedCompleter.isCompleted) { + _fullyLoadedCompleter.complete(); + } + } + Future _setMapLanguage() async { final controller = await _controller; if (!mounted) return; 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; @@ -327,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/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; + } + } +} 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: diff --git a/test/coalescing_runner_test.dart b/test/coalescing_runner_test.dart new file mode 100644 index 0000000..535cc32 --- /dev/null +++ b/test/coalescing_runner_test.dart @@ -0,0 +1,249 @@ +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', + ); + }); + }); +}