diff --git a/mindbox/CHANGELOG.md b/mindbox/CHANGELOG.md index ff78d00..48b3a82 100644 --- a/mindbox/CHANGELOG.md +++ b/mindbox/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +* Add `MindboxEmbeddedBlock` — an embedded block for a place from the admin panel. + ## 2.15.2 * Upgrade native Android SDK dependency to v2.15.2. diff --git a/mindbox/README.md b/mindbox/README.md index e4b7da1..b20b041 100644 --- a/mindbox/README.md +++ b/mindbox/README.md @@ -32,6 +32,55 @@ Learn how to send events to Mindbox. Create a new Operation class object and set Mindbox SDK helps handle push notifications. Configuration and usage instructions can be found in the SDK documentation [here](https://developers.mindbox.ru/docs/firebase-send-push-notifications-flutter), [here](https://developers.mindbox.ru/docs/huawei-send-push-notifications-flutter) and [here](https://developers.mindbox.ru/docs/ios-send-push-notifications-flutter). +### Embedded Blocks + +Mark a place in your layout with `MindboxEmbeddedBlock` and the SDK decides what goes into it from +the admin panel — the app never learns what the content is, and it can change without a release. +The host owns the size: pass the `height` the block should occupy. A place that ends up without +content collapses to zero height and hands the space back. + +```dart +MindboxEmbeddedBlock( + placeSystemName: 'main-screen-top', + height: 104, +) +``` + +Both outcomes can be customized, the same way as in SwiftUI and Compose: `placeholder` replaces the +stock loading shimmer, and `errorBuilder` opts into showing a failure instead of collapsing. An +empty place always collapses — a host cannot fill the space of a block that was never meant to be +there. `onLoad` and `onFail` report how the load ended. + +```dart +MindboxEmbeddedBlock( + placeSystemName: 'stories', + height: 104, + placeholder: (_) => const StoriesSkeleton(), + errorBuilder: (_) => const StoriesUnavailable(), + onFail: () => setState(() => _showStoriesSection = false), +) +``` + +How long a block may wait for its content before it gives the place back is `timeout`. Left out, it +is the SDK's own budget of 30 seconds. The wait is the user's: it is counted only while the screen +the block stands on is the one being looked at, so a block behind a pushed route keeps the remainder +of its budget for the return. + +```dart +MindboxEmbeddedBlock( + placeSystemName: 'stories', + height: 104, + timeout: const Duration(seconds: 5), +) +``` + +`height` is live: a new value resizes a block already on screen in place — the same content, no +reload. `timeout` is fixed when the block is created — a new value is ignored and reported to the +log; give the widget a new `Key` to load a block on a new budget. + +Available on iOS and Android. On any other platform the block collapses right away and reports +`onFail`, so a layout that hides its section on failure behaves the same everywhere. + ## Troubleshooting Refer to the [Example of integration(IOS)](https://github.com/mindbox-cloud/flutter-sdk/tree/develop/mindbox_ios/example) or [Example of integration(Android)](https://github.com/mindbox-cloud/flutter-sdk/tree/develop/mindbox_android/example) in case of any issues. diff --git a/mindbox/lib/mindbox.dart b/mindbox/lib/mindbox.dart index b7e0403..bd3c444 100644 --- a/mindbox/lib/mindbox.dart +++ b/mindbox/lib/mindbox.dart @@ -19,6 +19,7 @@ export 'package:mindbox_platform_interface/mindbox_platform_interface.dart' CustomInAppCallback, InAppClickHandler, InAppDismissedHandler; +export 'src/embedded_block.dart' show MindboxEmbeddedBlock; /// Basic Mindbox API. class Mindbox { diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart new file mode 100644 index 0000000..2943ea5 --- /dev/null +++ b/mindbox/lib/src/embedded_block.dart @@ -0,0 +1,420 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mindbox_platform_interface/mindbox_platform_interface.dart'; + +/// An embedded Mindbox block. +/// +/// The app marks a *place* by its [placeSystemName] and never learns what goes into it — that is the +/// config's decision, and it can change without an app release. **The host owns the size**: pass the +/// [height] the block should occupy. A place that ends up without content collapses to zero height +/// and hands the space back. +/// +/// ```dart +/// MindboxEmbeddedBlock( +/// placeSystemName: 'main-screen-top', +/// height: 104, +/// ) +/// ``` +/// +/// Both outcomes can be customized, the same way as in SwiftUI and Compose: [placeholder] replaces +/// the stock loading shimmer, and [errorBuilder] opts into showing a failure instead of collapsing. +/// Both stay ordinary widgets, built in place and mounted inside the block, so they resolve the +/// theme, the locale and the inherited objects of the tree the block itself stands in — and a +/// callback of the host works from them like from any other widget. +/// +/// ```dart +/// MindboxEmbeddedBlock( +/// placeSystemName: 'stories', +/// height: 104, +/// placeholder: (_) => const StoriesSkeleton(), +/// errorBuilder: (_) => const StoriesUnavailable(), +/// ) +/// ``` +/// +/// How long the block may wait before it gives its place back is the [timeout], and a host that +/// leaves it out gets the SDK's own budget of 30 seconds. +/// +/// The widget is a thin layer over the native block: the platform view holds the SDK's own container +/// — with its waiting budget and its web page — and this widget only mirrors the container's +/// decisions in the Flutter layout, and draws the host's own screens over it when it asks for them. +/// +/// **iOS and Android.** On any other platform the block collapses right away and reports [onFail], so +/// a layout that hides its section on failure behaves the same everywhere. +class MindboxEmbeddedBlock extends StatelessWidget { + /// Creates a block for the place named [placeSystemName], occupying [height]. + const MindboxEmbeddedBlock({ + Key? key, + required this.placeSystemName, + required this.height, + this.timeout, + this.placeholder, + this.errorBuilder, + this.onLoad, + this.onFail, + }) : super(key: key); + + /// The name of the place from the admin panel. A different name is a different block, built from + /// scratch in place of the old one. + /// + /// Taken exactly as given: nothing is trimmed, so spaces around the name are part of it and keep + /// the block from matching the place. The widget writes such a name to the log. + final String placeSystemName; + + /// The height the block occupies while it loads and while it is shown. Live: a new value given + /// to a live block resizes it in place — the same content, no reload — exactly as the SwiftUI + /// and Compose wrappers behave. + final double height; + + /// How long the block waits to learn what it shows before it gives its place back. `null` — the + /// default — is the SDK's own budget of 30 seconds. + /// + /// The budget covers the wait for the answer, not the whole life of the block: a page that has + /// already arrived gets its own time to render, and that is not shortened by a small timeout here. + /// An answer that comes in later no longer expands a block that has given up; the next attempt + /// starts when the block comes back on screen. + /// + /// The wait is the user's, not the clock's: it is counted only while the screen the block stands + /// on is the one being looked at, and a block left behind a pushed route keeps the remainder of + /// its budget for when the user comes back. + /// + /// Zero or negative is not a budget — such a block would collapse before the SDK could answer at + /// all — so the native side keeps its default instead and writes down what it was given. + /// + /// Fixed when the block is created: a new value given to a live block is ignored and reported + /// to the log. Give the widget a new [Key] to load a block on a new budget. + final Duration? timeout; + + /// Built instead of the SDK shimmer while the block is loading. + /// + /// Fills the whole place, as the native placeholder does: the widget is given the block's full + /// width and height as tight constraints. A screen that should be smaller says so itself, with an + /// [Align] or a [Center]; one that could be taller has to fit — anything over [height] overflows. + final WidgetBuilder? placeholder; + + /// Built instead of collapsing when the block cannot be shown. + /// + /// Applies only to failures: an empty place — one with nothing behind its place system name — + /// always collapses, so a host cannot fill the space of a block that was never meant to be there. + /// + /// Adding it to a block that has *already* collapsed does not bring the space back: reopening + /// space the layout has reclaimed would make it jump. Such a builder takes effect on a load that + /// starts the cycle anew, never on the silent retry a return to the screen brings. Passing it + /// from the start is what a host that wants a failure screen should do. + final WidgetBuilder? errorBuilder; + + /// The content is shown. + /// + /// Delivered once per outcome, not once per lifetime: the same outcome is never repeated, and an + /// outcome that actually changed — a place that filled up after a failure — is delivered again. + /// The native block reports the same way, so every wrapper of the SDK calls back alike. + final VoidCallback? onLoad; + + /// The place ended up without content: the load failed or timed out, or there is nothing behind + /// the name. An empty place is a normal outcome, not a breakage. + /// + /// Delivered on the same rule as [onLoad]: once per outcome, again if the outcome changes. + final VoidCallback? onFail; + + @override + Widget build(BuildContext context) { + return _EmbeddedBlock( + key: ValueKey(placeSystemName), + placeSystemName: placeSystemName, + height: height, + timeout: timeout, + placeholder: placeholder, + errorBuilder: errorBuilder, + onLoad: onLoad, + onFail: onFail, + ); + } +} + +class _EmbeddedBlock extends StatefulWidget { + const _EmbeddedBlock({ + Key? key, + required this.placeSystemName, + required this.height, + required this.timeout, + required this.placeholder, + required this.errorBuilder, + required this.onLoad, + required this.onFail, + }) : super(key: key); + + final String placeSystemName; + final double height; + final Duration? timeout; + final WidgetBuilder? placeholder; + final WidgetBuilder? errorBuilder; + final VoidCallback? onLoad; + final VoidCallback? onFail; + + @override + State<_EmbeddedBlock> createState() => _EmbeddedBlockState(); +} + +class _EmbeddedBlockState extends State<_EmbeddedBlock> { + double get _height => widget.height.isFinite ? math.max(0, widget.height) : 0; + + late final Duration? _creationTimeout; + + EmbeddedBlockAppearance _appearance = EmbeddedBlockAppearance.placeholder; + + EmbeddedBlockOutcome? _deliveredOutcome; + + bool _hasWarnedAboutTimeout = false; + + MethodChannel? _channel; + + bool? _syncedHasPlaceholder; + bool? _syncedHasErrorView; + bool? _syncedHostVisible; + + bool _isHostVisible = true; + + bool get _hasPlaceholder => widget.placeholder != null; + + bool get _hasErrorView => widget.errorBuilder != null; + + static bool get _isSupported => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android); + + @override + void initState() { + super.initState(); + _creationTimeout = widget.timeout; + _warnIfPlaceIsPadded(); + _warnIfHeightReservesNoSpace(); + if (!_isSupported) { + WidgetsFlutterBinding.ensureInitialized().addPostFrameCallback((_) { + if (!mounted) { + return; + } + setState(() => _appearance = EmbeddedBlockAppearance.collapsed); + _deliver(EmbeddedBlockOutcome.fail); + }); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // ignore: deprecated_member_use + _isHostVisible = TickerMode.of(context); + _pushHostVisible(); + } + + @override + void didUpdateWidget(covariant _EmbeddedBlock oldWidget) { + super.didUpdateWidget(oldWidget); + _warnIfTimeoutIsIgnored(); + _pushStandIns(); + } + + @override + void dispose() { + final MethodChannel? channel = _channel; + if (channel != null) { + if (defaultTargetPlatform == TargetPlatform.iOS) { + _invoke(channel, EmbeddedBlockMethods.release, null); + } + channel.setMethodCallHandler(null); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final Widget? hostLayer = _hostLayer(context); + + return SizedBox( + height: _appearance == EmbeddedBlockAppearance.collapsed ? 0 : _height, + child: Stack( + fit: StackFit.expand, + children: [ + _nativeBlock(), + if (hostLayer != null) hostLayer, + ], + ), + ); + } + + Widget? _hostLayer(BuildContext context) { + switch (_appearance) { + case EmbeddedBlockAppearance.placeholder: + return widget.placeholder?.call(context); + case EmbeddedBlockAppearance.error: + return widget.errorBuilder?.call(context); + case EmbeddedBlockAppearance.content: + case EmbeddedBlockAppearance.collapsed: + return null; + } + } + + Widget _nativeBlock() { + if (!_isSupported) { + return const SizedBox.shrink(); + } + + final Map creationParams = { + EmbeddedBlockParams.placeSystemName: widget.placeSystemName, + EmbeddedBlockParams.height: _height, + EmbeddedBlockParams.hasPlaceholder: _hasPlaceholder, + EmbeddedBlockParams.hasErrorView: _hasErrorView, + }; + + final Duration? timeout = _creationTimeout; + if (timeout != null) { + creationParams[EmbeddedBlockParams.timeoutMs] = timeout.inMilliseconds; + } + + final Set> gestureRecognizers = + _appearance == EmbeddedBlockAppearance.content + ? >{ + Factory( + () => HorizontalDragGestureRecognizer(), + ), + } + : const >{}; + + if (defaultTargetPlatform == TargetPlatform.android) { + return AndroidView( + viewType: embeddedBlockViewType, + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + gestureRecognizers: gestureRecognizers, + onPlatformViewCreated: _listenTo, + ); + } + + return UiKitView( + viewType: embeddedBlockViewType, + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + gestureRecognizers: gestureRecognizers, + onPlatformViewCreated: _listenTo, + ); + } + + void _listenTo(int viewId) { + _channel?.setMethodCallHandler(null); + _syncedHasPlaceholder = null; + _syncedHasErrorView = null; + _syncedHostVisible = null; + + final MethodChannel channel = MethodChannel(embeddedBlockChannelName(viewId)); + channel.setMethodCallHandler(_handle); + _channel = channel; + _invoke(channel, EmbeddedBlockMethods.sync, null); + _pushStandIns(); + _pushHostVisible(); + } + + Future _handle(MethodCall call) async { + if (call.method != EmbeddedBlockMethods.report) { + return; + } + + final EmbeddedBlockReport? report = EmbeddedBlockReport.tryParse(call.arguments); + if (report == null || !mounted) { + return; + } + + final EmbeddedBlockAppearance? appearance = report.appearance; + if (appearance != null && appearance != _appearance) { + setState(() => _appearance = appearance); + } + + _deliver(report.outcome); + } + + void _deliver(EmbeddedBlockOutcome? outcome) { + if (outcome == null || outcome == _deliveredOutcome) { + return; + } + + _deliveredOutcome = outcome; + if (outcome == EmbeddedBlockOutcome.load) { + widget.onLoad?.call(); + } else { + widget.onFail?.call(); + } + } + + void _pushStandIns() { + final MethodChannel? channel = _channel; + if (channel == null || + (_syncedHasPlaceholder == _hasPlaceholder && _syncedHasErrorView == _hasErrorView)) { + return; + } + + _syncedHasPlaceholder = _hasPlaceholder; + _syncedHasErrorView = _hasErrorView; + _invoke( + channel, + EmbeddedBlockMethods.setStandIns, + { + EmbeddedBlockParams.hasPlaceholder: _hasPlaceholder, + EmbeddedBlockParams.hasErrorView: _hasErrorView, + }, + ); + } + + void _pushHostVisible() { + final MethodChannel? channel = _channel; + if (channel == null || _syncedHostVisible == _isHostVisible) { + return; + } + + _syncedHostVisible = _isHostVisible; + _invoke(channel, EmbeddedBlockMethods.setHostVisible, _isHostVisible); + } + + void _invoke(MethodChannel channel, String method, Object? arguments) { + channel.invokeMethod(method, arguments).catchError((Object error) { + debugPrint('[MindboxEmbeddedBlock] $method for block "${widget.placeSystemName}" ' + 'was not delivered: $error'); + }); + } + + void _warnIfPlaceIsPadded() { + final String placeSystemName = widget.placeSystemName; + if (placeSystemName.trim() == placeSystemName) { + return; + } + + debugPrint( + '[MindboxEmbeddedBlock] Block "$placeSystemName" was given a place system name with spaces ' + 'around it. The name is used as it is, so it will not match the place from the admin panel.', + ); + } + + void _warnIfHeightReservesNoSpace() { + if (widget.height.isFinite && widget.height > 0) { + return; + } + + debugPrint( + '[MindboxEmbeddedBlock] Block "${widget.placeSystemName}" was created with height ' + '${widget.height}: it reserves no space and nothing loads.', + ); + } + + void _warnIfTimeoutIsIgnored() { + if (!_hasWarnedAboutTimeout && widget.timeout != _creationTimeout) { + _hasWarnedAboutTimeout = true; + debugPrint( + '[MindboxEmbeddedBlock] Block "${widget.placeSystemName}" was given timeout ' + '${widget.timeout} after creation and keeps $_creationTimeout: the timeout is fixed when ' + 'the block is created. Give the widget a new Key to load a block on a different budget.', + ); + } + } +} diff --git a/mindbox/test/embedded_block_test.dart b/mindbox/test/embedded_block_test.dart new file mode 100644 index 0000000..698b353 --- /dev/null +++ b/mindbox/test/embedded_block_test.dart @@ -0,0 +1,485 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mindbox/mindbox.dart'; +import 'package:mindbox_platform_interface/mindbox_platform_interface.dart'; + +void testWithoutNativeBlock(String description, Future Function(WidgetTester) body) { + testWidgets(description, (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + try { + await body(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); +} + +void main() { + group('On a platform without a native block', () { + testWithoutNativeBlock('The block collapses and reports a failure', + (WidgetTester tester) async { + int fails = 0; + int loads = 0; + + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: MindboxEmbeddedBlock( + placeSystemName: 'stories', + height: 104, + onLoad: () => loads++, + onFail: () => fails++, + ), + ), + )); + + expect(tester.getSize(find.byType(MindboxEmbeddedBlock)).height, 104); + + await tester.pump(); + + expect(tester.getSize(find.byType(MindboxEmbeddedBlock)).height, 0); + expect(fails, 1); + expect(loads, 0); + }); + + testWithoutNativeBlock('The failure is reported once, not on every rebuild', + (WidgetTester tester) async { + int fails = 0; + + Future build() => tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: MindboxEmbeddedBlock( + placeSystemName: 'stories', + height: 104, + onFail: () => fails++, + ), + ), + )); + + await build(); + await tester.pump(); + await build(); + await tester.pump(); + + expect(fails, 1); + }); + + testWithoutNativeBlock('A host placeholder fills the place while the block is loading', + (WidgetTester tester) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: MindboxEmbeddedBlock( + placeSystemName: 'stories', + height: 104, + placeholder: (_) => const SizedBox.expand(key: Key('host-placeholder')), + ), + ), + )); + + expect(find.byKey(const Key('host-placeholder')), findsOneWidget); + expect(tester.getSize(find.byKey(const Key('host-placeholder'))).height, 104); + }); + + testWithoutNativeBlock('An empty place shows no error screen, even when the host has one', + (WidgetTester tester) async { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: MindboxEmbeddedBlock( + placeSystemName: 'stories', + height: 104, + errorBuilder: (_) => const SizedBox.expand(key: Key('host-error')), + ), + ), + )); + await tester.pump(); + + expect(find.byKey(const Key('host-error')), findsNothing); + expect(tester.getSize(find.byType(MindboxEmbeddedBlock)).height, 0); + }); + + testWithoutNativeBlock('A different place is a different block', (WidgetTester tester) async { + int fails = 0; + + Future buildFor(String place) => tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: MindboxEmbeddedBlock( + placeSystemName: place, + height: 104, + onFail: () => fails++, + ), + ), + )); + + await buildFor('stories'); + await tester.pump(); + expect(fails, 1); + + await buildFor('promo'); + await tester.pump(); + expect(fails, 2); + }); + + testWithoutNativeBlock('A budget changed after creation is ignored, and said out loud', + (WidgetTester tester) async { + final List log = []; + final DebugPrintCallback printed = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) => log.add(message ?? ''); + + try { + Future buildWith(Duration timeout) => tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: MindboxEmbeddedBlock( + placeSystemName: 'stories', + height: 104, + timeout: timeout, + ), + ), + )); + + await buildWith(const Duration(seconds: 5)); + expect(log, isEmpty); + + await buildWith(const Duration(seconds: 9)); + await buildWith(const Duration(seconds: 12)); + } finally { + debugPrint = printed; + } + + expect(log.where((String line) => line.contains('timeout')), hasLength(1)); + expect(log.single, contains('"stories"')); + expect(log.single, contains('0:00:05')); + }); + }); + + group('The waiting budget', () { + late List> created; + + setUp(() { + created = >[]; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform_views, (MethodCall call) async { + if (call.method != 'create') { + return null; + } + + final Map arguments = call.arguments as Map; + final Uint8List params = arguments['params'] as Uint8List; + created.add(const StandardMessageCodec().decodeMessage( + params.buffer.asByteData(params.offsetInBytes, params.lengthInBytes), + ) as Map); + return 0; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform_views, null); + }); + + Future> paramsOf( + WidgetTester tester, + TargetPlatform platform, { + Duration? timeout, + }) async { + debugDefaultTargetPlatformOverride = platform; + try { + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: MindboxEmbeddedBlock( + placeSystemName: 'stories', + height: 104, + timeout: timeout, + ), + ), + )); + await tester.pumpAndSettle(); + } finally { + debugDefaultTargetPlatformOverride = null; + } + + expect(created, hasLength(1)); + return created.single; + } + + for (final TargetPlatform platform in [ + TargetPlatform.iOS, + TargetPlatform.android, + ]) { + final String name = platform == TargetPlatform.iOS ? 'iOS' : 'Android'; + + testWidgets('Reaches the $name block as whole milliseconds', + (WidgetTester tester) async { + final Map params = await paramsOf( + tester, + platform, + timeout: const Duration(milliseconds: 4500), + ); + + expect(params['placeSystemName'], 'stories'); + expect(params['timeoutMs'], 4500); + }); + + testWidgets('Is left out on $name when the host names none, so the SDK default stands', + (WidgetTester tester) async { + final Map params = await paramsOf(tester, platform); + + expect(params.containsKey('timeoutMs'), isFalse); + }); + } + + testWidgets('Goes down as it was given, for the native side to judge', + (WidgetTester tester) async { + final Map params = await paramsOf( + tester, + TargetPlatform.iOS, + timeout: Duration.zero, + ); + + expect(params['timeoutMs'], 0); + }); + }); + + group('A height that reserves no space', () { + Future buildWith(WidgetTester tester, String placeSystemName, double height) => + tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: MindboxEmbeddedBlock( + placeSystemName: placeSystemName, + height: height, + ), + ), + )); + + testWithoutNativeBlock('A block created with no height says so in the log', + (WidgetTester tester) async { + final List log = []; + final DebugPrintCallback printed = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) => log.add(message ?? ''); + + try { + await buildWith(tester, 'stories', 0); + await buildWith(tester, 'promo', -8); + } finally { + debugPrint = printed; + } + + final Iterable lines = + log.where((String line) => line.contains('reserves no space')); + expect(lines, hasLength(2)); + expect(lines.first, contains('"stories"')); + expect(lines.last, contains('"promo"')); + }); + + testWithoutNativeBlock('A block with a height says nothing', (WidgetTester tester) async { + final List log = []; + final DebugPrintCallback printed = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) => log.add(message ?? ''); + + try { + await buildWith(tester, 'stories', 104); + } finally { + debugPrint = printed; + } + + expect(log, isEmpty); + }); + }); + + group('A place name with spaces around it', () { + Future buildWith(WidgetTester tester, String placeSystemName) => + tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: MindboxEmbeddedBlock( + placeSystemName: placeSystemName, + height: 104, + ), + ), + )); + + testWithoutNativeBlock('A padded place name says so in the log', (WidgetTester tester) async { + final List log = []; + final DebugPrintCallback printed = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) => log.add(message ?? ''); + + try { + await buildWith(tester, ' stories'); + await buildWith(tester, 'promo '); + } finally { + debugPrint = printed; + } + + expect(log.where((String line) => line.contains('with spaces around it')), hasLength(2)); + }); + + testWithoutNativeBlock('A place name without them says nothing', (WidgetTester tester) async { + final List log = []; + final DebugPrintCallback printed = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) => log.add(message ?? ''); + + try { + await buildWith(tester, 'stories'); + } finally { + debugPrint = printed; + } + + expect(log, isEmpty); + }); + }); + + group('The block height', () { + late List> created; + + setUp(() { + created = >[]; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform_views, (MethodCall call) async { + final Map arguments = call.arguments as Map; + + // The engine answers a resize with the size it actually gave the platform view, and the + // controller reads it back — a bare null there fails inside the framework, not in the SDK. + if (call.method == 'resize') { + return { + 'width': arguments['width'], + 'height': arguments['height'], + }; + } + + if (call.method != 'create') { + return null; + } + + final Uint8List params = arguments['params'] as Uint8List; + created.add(const StandardMessageCodec().decodeMessage( + params.buffer.asByteData(params.offsetInBytes, params.lengthInBytes), + ) as Map); + return 0; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform_views, null); + }); + + Future buildWith(WidgetTester tester, double height) => tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: MindboxEmbeddedBlock( + placeSystemName: 'stories', + height: height, + ), + ), + )); + + testWidgets('A new height resizes the live block without rebuilding it', + (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final List log = []; + final DebugPrintCallback printed = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) => log.add(message ?? ''); + try { + await buildWith(tester, 104); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(MindboxEmbeddedBlock)).height, 104); + + await buildWith(tester, 200); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byType(MindboxEmbeddedBlock)).height, 200); + expect(created, hasLength(1)); + expect(log, isEmpty); + } finally { + debugPrint = printed; + debugDefaultTargetPlatformOverride = null; + } + }); + }); + + group('Leaving the screen', () { + late List methods; + + setUp(() { + methods = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform_views, (MethodCall call) async { + if (call.method != 'create') { + return null; + } + + final Map arguments = call.arguments as Map; + final int viewId = arguments['id']! as int; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + MethodChannel(embeddedBlockChannelName(viewId)), + (MethodCall call) async { + methods.add(call.method); + return null; + }, + ); + return 0; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform_views, null); + }); + + Future showAndDrop(WidgetTester tester, TargetPlatform platform) async { + debugDefaultTargetPlatformOverride = platform; + try { + await tester.pumpWidget(const Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: MindboxEmbeddedBlock( + placeSystemName: 'stories', + height: 104, + ), + ), + )); + await tester.pumpAndSettle(); + + expect(methods, contains(EmbeddedBlockMethods.sync)); + expect(methods, isNot(contains(EmbeddedBlockMethods.release))); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + } finally { + debugDefaultTargetPlatformOverride = null; + } + } + + testWidgets('A disposed widget tells the iOS block to stop', (WidgetTester tester) async { + await showAndDrop(tester, TargetPlatform.iOS); + + expect(methods.last, EmbeddedBlockMethods.release); + }); + + testWidgets('A disposed widget leaves the Android block to its own dispose hook', + (WidgetTester tester) async { + await showAndDrop(tester, TargetPlatform.android); + + expect(methods, isNot(contains(EmbeddedBlockMethods.release))); + }); + }); +} diff --git a/mindbox_android/CHANGELOG.md b/mindbox_android/CHANGELOG.md index 4a7e745..9f8d510 100644 --- a/mindbox_android/CHANGELOG.md +++ b/mindbox_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +* Add the embedded block platform view over the native `MindboxEmbeddedBlockView`. + ## 2.15.2 * Upgrade native Android SDK dependency to v2.15.2. diff --git a/mindbox_android/android/src/main/kotlin/cloud/mindbox/mindbox_android/EmbeddedBlockPlatformView.kt b/mindbox_android/android/src/main/kotlin/cloud/mindbox/mindbox_android/EmbeddedBlockPlatformView.kt new file mode 100644 index 0000000..148c1b7 --- /dev/null +++ b/mindbox_android/android/src/main/kotlin/cloud/mindbox/mindbox_android/EmbeddedBlockPlatformView.kt @@ -0,0 +1,200 @@ +package cloud.mindbox.mindbox_android + +import android.content.Context +import android.graphics.Color +import android.view.View +import cloud.mindbox.mobile_sdk.Mindbox +import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi +import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockAppearance +import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockListener +import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockView +import cloud.mindbox.mobile_sdk.logger.Level +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.StandardMessageCodec +import io.flutter.plugin.platform.PlatformView +import io.flutter.plugin.platform.PlatformViewFactory + +@OptIn(InternalMindboxApi::class) +internal class EmbeddedBlockPlatformViewFactory( + private val messenger: BinaryMessenger, +) : PlatformViewFactory(StandardMessageCodec.INSTANCE) { + + override fun create(context: Context, viewId: Int, args: Any?): PlatformView = + EmbeddedBlockPlatformView(context, viewId, args, messenger) +} + +@OptIn(InternalMindboxApi::class) +internal class EmbeddedBlockPlatformView( + private val context: Context, + viewId: Int, + arguments: Any?, + messenger: BinaryMessenger, +) : PlatformView, MethodChannel.MethodCallHandler { + + private val blockView: MindboxEmbeddedBlockView + private val channel: MethodChannel + + private var appearance = PLACEHOLDER + private var outcome: String? = null + + private var placeholderStandIn: View? = null + private var errorStandIn: View? = null + + init { + val params = arguments as? Map<*, *> + val placeSystemName = params?.get(KEY_PLACE_SYSTEM_NAME) as? String ?: "" + + val timeoutMs = (params?.get(KEY_TIMEOUT_MS) as? Number)?.toLong() + + blockView = MindboxEmbeddedBlockView(context, placeSystemName, timeoutMs) + channel = MethodChannel(messenger, "$VIEW_TYPE/$viewId") + + if (placeSystemName.isEmpty()) { + Mindbox.writeLog( + message = "[EmbeddedBlock] A Flutter block was created without a place system name " + + "and has nothing to resolve", + logLevel = Level.ERROR, + ) + } + + syncStandIns( + hasPlaceholder = params?.get(KEY_HAS_PLACEHOLDER) as? Boolean ?: false, + hasErrorView = params?.get(KEY_HAS_ERROR_VIEW) as? Boolean ?: false, + ) + + channel.setMethodCallHandler(this) + blockView.setListener( + object : MindboxEmbeddedBlockListener { + override fun onLoad(view: MindboxEmbeddedBlockView) = report(outcome = LOAD) + + override fun onFail(view: MindboxEmbeddedBlockView) = report(outcome = FAIL) + }, + ) + blockView.setAppearanceObserver { appearance -> report(appearance) } + } + + override fun getView(): View = blockView + + override fun dispose() { + blockView.setAppearanceObserver(null) + blockView.setListener(null) + blockView.release() + channel.setMethodCallHandler(null) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + METHOD_SYNC -> { + send() + result.success(null) + } + METHOD_SET_HOST_VISIBLE -> { + val isHostVisible = call.arguments as? Boolean + if (isHostVisible == null) { + result.error(ERROR_BAD_ARGUMENTS, "setHostVisible expects a boolean", null) + return + } + blockView.setHostVisible(isHostVisible) + result.success(null) + } + METHOD_SET_STAND_INS -> { + val arguments = call.arguments as? Map<*, *> + val hasPlaceholder = arguments?.get(KEY_HAS_PLACEHOLDER) as? Boolean + val hasErrorView = arguments?.get(KEY_HAS_ERROR_VIEW) as? Boolean + if (hasPlaceholder == null || hasErrorView == null) { + result.error( + ERROR_BAD_ARGUMENTS, + "setStandIns expects hasPlaceholder and hasErrorView booleans", + null, + ) + return + } + syncStandIns(hasPlaceholder = hasPlaceholder, hasErrorView = hasErrorView) + result.success(null) + } + METHOD_RELEASE -> { + blockView.release() + result.success(null) + } + else -> result.notImplemented() + } + } + + private fun syncStandIns(hasPlaceholder: Boolean, hasErrorView: Boolean) { + if (hasPlaceholder) { + if (placeholderStandIn == null) { + placeholderStandIn = makeStandIn() + blockView.setPlaceholderView(placeholderStandIn) + } + } else if (placeholderStandIn != null) { + placeholderStandIn = null + blockView.setPlaceholderView(null) + } + + if (hasErrorView) { + if (errorStandIn == null) { + errorStandIn = makeStandIn() + blockView.setErrorView(errorStandIn) + } + } else if (errorStandIn != null) { + errorStandIn = null + blockView.setErrorView(null) + } + } + + private fun makeStandIn(): View = View(context).apply { + setBackgroundColor(Color.TRANSPARENT) + isClickable = false + isFocusable = false + } + + private fun report(appearance: MindboxEmbeddedBlockAppearance) { + this.appearance = nameOf(appearance) + send() + } + + private fun report(outcome: String) { + this.outcome = outcome + send() + } + + private fun send() { + val arguments = mutableMapOf(KEY_APPEARANCE to appearance) + outcome?.let { arguments[KEY_OUTCOME] = it } + channel.invokeMethod(METHOD_REPORT, arguments) + } + + private companion object { + + const val VIEW_TYPE = EMBEDDED_BLOCK_VIEW_TYPE + const val KEY_PLACE_SYSTEM_NAME = "placeSystemName" + const val KEY_TIMEOUT_MS = "timeoutMs" + const val KEY_HAS_PLACEHOLDER = "hasPlaceholder" + const val KEY_HAS_ERROR_VIEW = "hasErrorView" + const val KEY_APPEARANCE = "appearance" + const val KEY_OUTCOME = "outcome" + const val METHOD_REPORT = "report" + const val METHOD_SYNC = "sync" + const val METHOD_SET_HOST_VISIBLE = "setHostVisible" + const val METHOD_SET_STAND_INS = "setStandIns" + const val METHOD_RELEASE = "release" + const val ERROR_BAD_ARGUMENTS = "bad_arguments" + const val LOAD = "load" + const val FAIL = "fail" + const val PLACEHOLDER = "placeholder" + const val CONTENT = "content" + const val ERROR = "error" + const val COLLAPSED = "collapsed" + + fun nameOf(appearance: MindboxEmbeddedBlockAppearance): String = when (appearance) { + MindboxEmbeddedBlockAppearance.PLACEHOLDER -> PLACEHOLDER + MindboxEmbeddedBlockAppearance.CONTENT -> CONTENT + MindboxEmbeddedBlockAppearance.ERROR -> ERROR + MindboxEmbeddedBlockAppearance.COLLAPSED -> COLLAPSED + } + } +} + +internal const val EMBEDDED_BLOCK_VIEW_TYPE = "mindbox.cloud/flutter-sdk/embedded_block" diff --git a/mindbox_android/android/src/main/kotlin/cloud/mindbox/mindbox_android/MindboxAndroidPlugin.kt b/mindbox_android/android/src/main/kotlin/cloud/mindbox/mindbox_android/MindboxAndroidPlugin.kt index bb96a16..c2ede8d 100644 --- a/mindbox_android/android/src/main/kotlin/cloud/mindbox/mindbox_android/MindboxAndroidPlugin.kt +++ b/mindbox_android/android/src/main/kotlin/cloud/mindbox/mindbox_android/MindboxAndroidPlugin.kt @@ -56,6 +56,10 @@ class MindboxAndroidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, Ne override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { channel = MethodChannel(flutterPluginBinding.binaryMessenger, "mindbox.cloud/flutter-sdk") channel.setMethodCallHandler(this) + flutterPluginBinding.platformViewRegistry.registerViewFactory( + EMBEDDED_BLOCK_VIEW_TYPE, + EmbeddedBlockPlatformViewFactory(flutterPluginBinding.binaryMessenger), + ) } override fun onMethodCall(call: MethodCall, result: Result) { diff --git a/mindbox_ios/CHANGELOG.md b/mindbox_ios/CHANGELOG.md index 4c20726..77f4ca4 100644 --- a/mindbox_ios/CHANGELOG.md +++ b/mindbox_ios/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +* Add the embedded block platform view over the native `MindboxEmbeddedBlockView`. + ## 2.15.2 * Upgrade native iOS SDK dependency to v2.15.1. diff --git a/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/Constants.swift b/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/Constants.swift index b1e6252..89bbb57 100644 --- a/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/Constants.swift +++ b/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/Constants.swift @@ -9,4 +9,6 @@ import Foundation enum Constants { static let pluginChannelName = "mindbox.cloud/flutter-sdk"; + + static let embeddedBlockViewType = "mindbox.cloud/flutter-sdk/embedded_block"; } diff --git a/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift b/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift new file mode 100644 index 0000000..694c68f --- /dev/null +++ b/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift @@ -0,0 +1,195 @@ +import Flutter +import UIKit +@_spi(Internal) import Mindbox +import MindboxLogger + +public final class EmbeddedBlockPlatformViewFactory: NSObject, FlutterPlatformViewFactory { + + private let messenger: FlutterBinaryMessenger + + public init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + + public func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + FlutterStandardMessageCodec.sharedInstance() + } + + public func create(withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any?) -> FlutterPlatformView { + EmbeddedBlockPlatformView(viewId: viewId, arguments: args, messenger: messenger) + } +} + +final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { + + private let blockView: MindboxEmbeddedBlockView + private let channel: FlutterMethodChannel + + private var appearance = Keys.placeholder + private var outcome: String? + + init(viewId: Int64, arguments: Any?, messenger: FlutterBinaryMessenger) { + let params = arguments as? [String: Any] + let placeSystemName = params?[Keys.placeSystemName] as? String ?? "" + let height = (params?[Keys.height] as? NSNumber)?.doubleValue ?? 0 + let timeout = (params?[Keys.timeoutMs] as? NSNumber).map { TimeInterval($0.doubleValue) / 1000 } + + blockView = MindboxEmbeddedBlockView(placeSystemName: placeSystemName, + height: CGFloat(height), + timeout: timeout) + channel = FlutterMethodChannel(name: "\(Constants.embeddedBlockViewType)/\(viewId)", + binaryMessenger: messenger) + super.init() + + if placeSystemName.isEmpty { + Logger.common(message: "[EmbeddedBlock] A Flutter block was created without a place system name and has nothing to resolve", + level: .error, + category: .embeddedBlocks) + } + + syncStandIns(hasPlaceholder: params?[Keys.hasPlaceholder] as? Bool ?? false, + hasErrorView: params?[Keys.hasErrorView] as? Bool ?? false) + + blockView.delegate = self + blockView.setAppearanceObserver { [weak self] appearance in + self?.report(appearance: appearance) + } + channel.setMethodCallHandler { [weak self] call, result in + self?.handle(call, result: result) + } + } + + deinit { + blockView.release() + channel.setMethodCallHandler(nil) + } + + func view() -> UIView { + blockView + } + + private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case Keys.sync: + send() + result(nil) + case Keys.setHostVisible: + guard let isHostVisible = call.arguments as? Bool else { + result(FlutterError(code: "bad_arguments", + message: "setHostVisible expects a boolean", + details: nil)) + return + } + + blockView.setHostVisible(isHostVisible) + result(nil) + case Keys.setStandIns: + guard let arguments = call.arguments as? [String: Any], + let hasPlaceholder = arguments[Keys.hasPlaceholder] as? Bool, + let hasErrorView = arguments[Keys.hasErrorView] as? Bool else { + result(FlutterError(code: "bad_arguments", + message: "setStandIns expects hasPlaceholder and hasErrorView booleans", + details: nil)) + return + } + + syncStandIns(hasPlaceholder: hasPlaceholder, hasErrorView: hasErrorView) + result(nil) + case Keys.release: + blockView.release() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + private func syncStandIns(hasPlaceholder: Bool, hasErrorView: Bool) { + if hasPlaceholder { + if blockView.placeholderView == nil { + blockView.placeholderView = Self.makeStandIn() + } + } else { + blockView.placeholderView = nil + } + + if hasErrorView { + if blockView.errorView == nil { + blockView.errorView = Self.makeStandIn() + } + } else { + blockView.errorView = nil + } + } + + private static func makeStandIn() -> UIView { + let standIn = UIView() + standIn.backgroundColor = .clear + standIn.isUserInteractionEnabled = false + return standIn + } + + private func report(appearance: MindboxEmbeddedBlockAppearance) { + self.appearance = Keys.name(of: appearance) + send() + } + + private func report(outcome: String) { + self.outcome = outcome + send() + } + + private func send() { + var arguments: [String: Any] = [Keys.appearance: appearance] + if let outcome = outcome { + arguments[Keys.outcome] = outcome + } + + channel.invokeMethod(Keys.report, arguments: arguments) + } + + private enum Keys { + static let placeSystemName = "placeSystemName" + static let height = "height" + static let timeoutMs = "timeoutMs" + static let hasPlaceholder = "hasPlaceholder" + static let hasErrorView = "hasErrorView" + static let report = "report" + static let sync = "sync" + static let setHostVisible = "setHostVisible" + static let setStandIns = "setStandIns" + static let release = "release" + static let appearance = "appearance" + static let outcome = "outcome" + static let load = "load" + static let fail = "fail" + static let placeholder = "placeholder" + static let content = "content" + static let error = "error" + static let collapsed = "collapsed" + + static func name(of appearance: MindboxEmbeddedBlockAppearance) -> String { + switch appearance { + case .placeholder: return placeholder + case .content: return content + case .error: return error + case .collapsed: return collapsed + } + } + } +} + +// MARK: - MindboxEmbeddedBlockViewDelegate + +extension EmbeddedBlockPlatformView: MindboxEmbeddedBlockViewDelegate { + + func mindboxEmbeddedBlockViewDidLoad(_ blockView: MindboxEmbeddedBlockView) { + report(outcome: Keys.load) + } + + func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView) { + report(outcome: Keys.fail) + } +} diff --git a/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/MindboxIosPlugin.swift b/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/MindboxIosPlugin.swift index 8c29877..2ed56aa 100644 --- a/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/MindboxIosPlugin.swift +++ b/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/MindboxIosPlugin.swift @@ -15,6 +15,8 @@ public class MindboxIosPlugin: NSObject, FlutterPlugin { registrar.addMethodCallDelegate(instance, channel: channel) registrar.addApplicationDelegate(instance) + registrar.register(EmbeddedBlockPlatformViewFactory(messenger: registrar.messenger()), + withId: Constants.embeddedBlockViewType) } init(channel: FlutterMethodChannel) { diff --git a/mindbox_platform_interface/CHANGELOG.md b/mindbox_platform_interface/CHANGELOG.md index 0b19b14..494174b 100644 --- a/mindbox_platform_interface/CHANGELOG.md +++ b/mindbox_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +* Add the embedded block's platform contract: the view type, the per-block channel and its reports. + ## 2.15.2 * Upgrade native Android SDK dependency to v2.15.2. diff --git a/mindbox_platform_interface/lib/mindbox_platform_interface.dart b/mindbox_platform_interface/lib/mindbox_platform_interface.dart index eb4af66..3003288 100644 --- a/mindbox_platform_interface/lib/mindbox_platform_interface.dart +++ b/mindbox_platform_interface/lib/mindbox_platform_interface.dart @@ -1,4 +1,5 @@ export 'src/channel.dart'; +export 'src/embedded_block.dart'; export 'src/errors/mindbox_error.dart'; export 'src/mindbox_platform.dart'; export 'src/types/inapp_callbacks.dart'; diff --git a/mindbox_platform_interface/lib/src/embedded_block.dart b/mindbox_platform_interface/lib/src/embedded_block.dart new file mode 100644 index 0000000..c058516 --- /dev/null +++ b/mindbox_platform_interface/lib/src/embedded_block.dart @@ -0,0 +1,181 @@ +/// What the embedded block needs on both sides of the platform boundary. +/// +/// The block is a view, not a call, so nothing here lands on `MindboxPlatform`: what crosses the +/// boundary is a platform view type, one channel per created view, and the two signals the native +/// block sends up — how it occupies its place and how its load ended. + +/// The type both native factories register the block under. +const String embeddedBlockViewType = 'mindbox.cloud/flutter-sdk/embedded_block'; + +/// The channel of one created block. +/// +/// Per view and not per plugin: a screen may hold several blocks, and each of them reports on its +/// own. +String embeddedBlockChannelName(int viewId) => '$embeddedBlockViewType/$viewId'; + +/// Keys of the creation params the native factory reads. +class EmbeddedBlockParams { + EmbeddedBlockParams._(); + + /// The name of the place from the admin panel — what the native block resolves its content by. + static const String placeSystemName = 'placeSystemName'; + + /// The height the block occupies, in logical pixels. Read only by the iOS factory: on Android the + /// block is sized by the platform view it is placed in. + static const String height = 'height'; + + /// How long the block may wait to learn what it shows, in whole milliseconds. Absent means the + /// host said nothing and the native default stands. + /// + /// Milliseconds and not a [Duration]: what crosses the boundary is what the standard codec + /// carries, and each native side spells the budget its own way — seconds on iOS, milliseconds on + /// Android. The integer is the one spelling both can read. + static const String timeoutMs = 'timeoutMs'; + + /// Whether the host draws a loading screen of its own. + /// + /// Not the screen itself: a Flutter widget cannot be handed to a native container, and a widget + /// that could would leave its tree and lose the theme, the locale and the inherited objects it was + /// written against. The container is told only that the place is taken, and answers by holding + /// back its own shimmer. + static const String hasPlaceholder = 'hasPlaceholder'; + + /// Whether the host draws a failure of its own — the same arrangement as [hasPlaceholder], with + /// one difference: this is also what opts the block into showing a failure at all. Without it a + /// failed block collapses. + static const String hasErrorView = 'hasErrorView'; +} + +/// Methods of the per-view channel. +class EmbeddedBlockMethods { + EmbeddedBlockMethods._(); + + /// Native → Dart: where the block stands now, as an [EmbeddedBlockReport]. + static const String report = 'report'; + + /// Dart → native: report where the block stands, whatever it is. + /// + /// Asked once, as soon as the channel has a handler. The native block hands out its appearance the + /// moment the wrapper subscribes — which happens while the platform view is being built, before + /// Dart can listen — and a place with nothing behind it settles synchronously right there. Without + /// this the first report of such a block goes to a channel nobody is on yet, and the widget waits + /// out its whole life on a loading screen for a block that already collapsed. + static const String sync = 'sync'; + + /// Dart → native: whether the host still shows the block. + static const String setHostVisible = 'setHostVisible'; + + /// Dart → native: whether the host draws its own placeholder and failure, as the two + /// [EmbeddedBlockParams] booleans. + /// + /// The same answer as the creation params, for a block that is already live: the host may gain or + /// lose either screen between builds. + static const String setStandIns = 'setStandIns'; + + /// Dart → native: the widget is gone — stop the block now, not when the last reference to it is. + /// + /// Sent on iOS and nowhere else. Android has a dispose hook for the platform view and iOS has + /// none, so there the block would be released by `deinit` — whenever the engine happens to let go + /// of the view. Dart knows the moment exactly, and a page loading for a screen that no longer + /// exists is what the wait costs. + /// + /// Android is not told, and not merely because its hook already does it: the platform view is a + /// child of the widget that owns this channel, and children are unmounted first, so by the time + /// the widget could speak the hook has run — releasing the block and taking the channel's handler + /// down with it. The message would reach a channel nobody is on and come back as a missing plugin. + /// The Android side still answers it, for a host that says it by another road. + static const String release = 'release'; +} + +/// How the block occupies its place right now — what the wrapper draws, not what happened. +/// +/// The rules behind the decision stay in the native container: the content states, the rule that an +/// empty place shows no failure, the one that a place taken by loading is a place drawn. Dart +/// mirrors the answer in its layout and nothing more, so every wrapper of the SDK shows the same +/// thing at the same moment by construction. +enum EmbeddedBlockAppearance { + /// The content is loading. A host with a placeholder of its own draws it; without one the + /// container's shimmer is already on screen. + placeholder, + + /// The block content is shown — the host draws nothing over it. + content, + + /// The block failed and the host opted into showing it. Never appears for an empty place. + error, + + /// The block occupies no space: a failure without a host failure screen, or an empty place. The + /// space goes back to the layout. + collapsed, +} + +/// How the block's load ended. There are two outcomes and no more: the block is either shown or it +/// is not — an empty place reaches the host as [EmbeddedBlockOutcome.fail], the same as a failure. +enum EmbeddedBlockOutcome { + /// The content is shown. + load, + + /// The place ended up without content — the load failed or timed out, or there was nothing + /// behind the name. + fail, +} + +/// What the native block says about itself. +class EmbeddedBlockReport { + /// Both parts are optional: a message carries whichever of them it has to say. + const EmbeddedBlockReport({this.appearance, this.outcome}); + + /// What to draw, or `null` when the message carries no answer this version understands. + /// + /// The appearance is a state and not an event: the same value arrives more than once, and the + /// host keeps the last one it knew when a message brings none. + final EmbeddedBlockAppearance? appearance; + + /// How the load ended, or `null` while it has not ended. + /// + /// Sent apart from [appearance] because the two are decided apart: the container settles its + /// layers inside its own state change and delivers the outcome on the next turn of the main + /// queue. Deriving one from the other would move the host's callback to the wrong moment. + final EmbeddedBlockOutcome? outcome; + + /// Reads a report off the channel, or `null` if the message is not one. + /// + /// Tolerant on purpose: a native side newer than the Dart one may send fields — or appearances — + /// this version does not know, and that is no reason to break the block. + static EmbeddedBlockReport? tryParse(Object? arguments) { + if (arguments is! Map) { + return null; + } + + return EmbeddedBlockReport( + appearance: _appearanceOf(arguments[_appearanceKey]), + outcome: _outcomeOf(arguments[_outcomeKey]), + ); + } + + static EmbeddedBlockAppearance? _appearanceOf(Object? raw) => + _appearances[raw]; + + static EmbeddedBlockOutcome? _outcomeOf(Object? raw) { + if (raw == _loadOutcome) { + return EmbeddedBlockOutcome.load; + } + if (raw == _failOutcome) { + return EmbeddedBlockOutcome.fail; + } + return null; + } + + static const Map _appearances = + { + 'placeholder': EmbeddedBlockAppearance.placeholder, + 'content': EmbeddedBlockAppearance.content, + 'error': EmbeddedBlockAppearance.error, + 'collapsed': EmbeddedBlockAppearance.collapsed, + }; + + static const String _appearanceKey = 'appearance'; + static const String _outcomeKey = 'outcome'; + static const String _loadOutcome = 'load'; + static const String _failOutcome = 'fail'; +} diff --git a/mindbox_platform_interface/test/src/embedded_block_test.dart b/mindbox_platform_interface/test/src/embedded_block_test.dart new file mode 100644 index 0000000..ff1f4f1 --- /dev/null +++ b/mindbox_platform_interface/test/src/embedded_block_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mindbox_platform_interface/src/embedded_block.dart'; + +void main() { + group('Channel naming', () { + test('Every created block gets a channel of its own', () { + expect(embeddedBlockChannelName(0), '$embeddedBlockViewType/0'); + expect(embeddedBlockChannelName(7), '$embeddedBlockViewType/7'); + expect(embeddedBlockChannelName(1) == embeddedBlockChannelName(2), isFalse); + }); + + test('The view type is the one both native factories register', () { + expect(embeddedBlockViewType, 'mindbox.cloud/flutter-sdk/embedded_block'); + }); + }); + + group('EmbeddedBlockReport.tryParse', () { + test('Reads both parts of a full report', () { + final EmbeddedBlockReport? report = EmbeddedBlockReport.tryParse( + {'appearance': 'content', 'outcome': 'load'}, + ); + + expect(report, isNotNull); + expect(report!.appearance, EmbeddedBlockAppearance.content); + expect(report.outcome, EmbeddedBlockOutcome.load); + }); + + test('Every appearance the native side can send is understood', () { + const Map wire = + { + 'placeholder': EmbeddedBlockAppearance.placeholder, + 'content': EmbeddedBlockAppearance.content, + 'error': EmbeddedBlockAppearance.error, + 'collapsed': EmbeddedBlockAppearance.collapsed, + }; + + wire.forEach((String word, EmbeddedBlockAppearance expected) { + final EmbeddedBlockReport? report = + EmbeddedBlockReport.tryParse({'appearance': word}); + expect(report?.appearance, expected, reason: word); + }); + expect(wire.length, EmbeddedBlockAppearance.values.length); + }); + + test('An absent outcome is not an outcome', () { + final EmbeddedBlockReport? report = EmbeddedBlockReport.tryParse( + {'appearance': 'placeholder'}, + ); + + expect(report?.appearance, EmbeddedBlockAppearance.placeholder); + expect(report?.outcome, isNull); + }); + + test('A failure reads as fail', () { + final EmbeddedBlockReport? report = EmbeddedBlockReport.tryParse( + {'appearance': 'collapsed', 'outcome': 'fail'}, + ); + + expect(report?.outcome, EmbeddedBlockOutcome.fail); + }); + + test('A newer native side may send words this version does not know', () { + final EmbeddedBlockReport? report = EmbeddedBlockReport.tryParse( + {'appearance': 'sideways', 'outcome': 'maybe', 'extra': 1}, + ); + + expect(report, isNotNull); + expect(report!.appearance, isNull); + expect(report.outcome, isNull); + }); + + test('Anything that is not a map is not a report', () { + expect(EmbeddedBlockReport.tryParse(null), isNull); + expect(EmbeddedBlockReport.tryParse('report'), isNull); + expect(EmbeddedBlockReport.tryParse(['content']), isNull); + }); + }); +}