diff --git a/lib/managers/ar_object_manager.dart b/lib/managers/ar_object_manager.dart index 608ba464..789449b9 100644 --- a/lib/managers/ar_object_manager.dart +++ b/lib/managers/ar_object_manager.dart @@ -145,4 +145,22 @@ class ARObjectManager { removeNode(ARNode node) { _channel.invokeMethod('removeNode', {'name': node.name}); } + + /// Pushes the given node's current transformation to the underlying AR scene. + /// Call this after programmatically changing a node's [ARNode.transform], + /// [ARNode.position], [ARNode.rotation], [ARNode.scale] or [ARNode.eulerAngles] + /// to synchronize the change with the native AR engine. + /// + /// Fire-and-forget (like [removeNode]): it is intentionally not awaitable + /// because the Android side only sends a reply when pan/rotation handling is + /// enabled, so awaiting would hang when gestures are disabled. On Android the + /// transform is applied only when `handlePans` or `handleRotation` was enabled + /// in [ARSessionManager.onInitialize]. + void updateNode(ARNode node) { + _channel.invokeMethod('transformationChanged', { + 'name': node.name, + 'transformation': + const MatrixValueNotifierConverter().toJson(node.transformNotifier), + }); + } } diff --git a/lib/managers/ar_session_manager.dart b/lib/managers/ar_session_manager.dart index deee08ba..74541663 100644 --- a/lib/managers/ar_session_manager.dart +++ b/lib/managers/ar_session_manager.dart @@ -29,10 +29,10 @@ class ARSessionManager { final PlaneDetectionConfig planeDetectionConfig; /// Receives hit results from user taps with tracked planes or feature points - late ARHitResultHandler onPlaneOrPointTap; + ARHitResultHandler? onPlaneOrPointTap; /// Receives total number of Planes when a plane is detected and added to the view - late ARPlaneResultHandler onPlaneDetected; + ARPlaneResultHandler? onPlaneDetected; /// Callback that is triggered once error is triggered ErrorHandler? onError; @@ -158,13 +158,13 @@ class ARSessionManager { final hitTestResults = serializedHitTestResults.map((e) { return ARHitTestResult.fromJson(e); }).toList(); - onPlaneOrPointTap(hitTestResults); + onPlaneOrPointTap?.call(hitTestResults); } break; case 'onPlaneDetected': if (onPlaneDetected != null) { final planeCountResult = call.arguments as int; - onPlaneDetected(planeCountResult); + onPlaneDetected?.call(planeCountResult); } break; case 'dispose': diff --git a/pubspec.yaml b/pubspec.yaml index 85b13936..e0a59e11 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,13 +4,13 @@ version: 0.0.3 repository: https://github.com/hlefe/ar_flutter_plugin_2 environment: - sdk: ">=2.16.1 <3.0.0" + sdk: ">=2.16.1 <4.0.0" flutter: ">=1.20.0" dependencies: flutter: sdk: flutter - permission_handler: ^11.3.1 + permission_handler: ">=11.3.1 <13.0.0" vector_math: ^2.1.1 json_annotation: ^4.9.0 geolocator: ^12.0.0 diff --git a/test/ar_flutter_plugin_test.dart b/test/ar_flutter_plugin_test.dart index b229d62d..8b8d8e7a 100644 --- a/test/ar_flutter_plugin_test.dart +++ b/test/ar_flutter_plugin_test.dart @@ -1,6 +1,12 @@ +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ar_flutter_plugin_2/ar_flutter_plugin.dart'; +import 'package:ar_flutter_plugin_2/managers/ar_session_manager.dart'; +import 'package:ar_flutter_plugin_2/managers/ar_object_manager.dart'; +import 'package:ar_flutter_plugin_2/datatypes/config_planedetection.dart'; +import 'package:ar_flutter_plugin_2/datatypes/node_types.dart'; +import 'package:ar_flutter_plugin_2/models/ar_node.dart'; void main() { const MethodChannel channel = MethodChannel('ar_flutter_plugin_2'); @@ -20,4 +26,51 @@ void main() { test('getPlatformVersion', () async { expect(await ArFlutterPlugin.platformVersion, '42'); }); + + // Regression test for upstream issue #8: the session event handlers used to + // be declared `late` and non-nullable, so reading them before the app + // assigned one threw `LateInitializationError: Field 'onPlaneDetected' has + // not been initialized.` They must now be nullable and default to null. + testWidgets('session event handlers are null-safe when unset (issue #8)', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + final context = tester.element(find.byType(SizedBox)); + + final sessionManager = + ARSessionManager(0, context, PlaneDetectionConfig.none); + + expect(sessionManager.onPlaneOrPointTap, isNull); + expect(sessionManager.onPlaneDetected, isNull); + }); + + // Regression test for upstream issue #6: `updateNode` did not exist even + // though it was the documented way to sync transform changes. It must push a + // `transformationChanged` call (the method the native side already handles). + test('updateNode pushes transformationChanged to the object channel (issue #6)', + () async { + const objectChannel = MethodChannel('arobjects_0'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final calls = []; + messenger.setMockMethodCallHandler(objectChannel, (call) async { + calls.add(call); + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(objectChannel, null)); + + final objectManager = ARObjectManager(0); + final node = ARNode( + type: NodeType.localGLTF2, uri: 'model.gltf', name: 'myNode'); + + // updateNode is fire-and-forget; flush the event queue so the mock handler + // records the dispatched platform call before asserting. + objectManager.updateNode(node); + await pumpEventQueue(); + + expect(calls.single.method, 'transformationChanged'); + final args = calls.single.arguments as Map; + expect(args['name'], 'myNode'); + expect(args['transformation'], isA>()); + expect((args['transformation'] as List).length, 16); + }); }