From 9cf11b665d4791aa4d9debdaf377f9dc639647e7 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 17 Aug 2026 17:29:05 +0500 Subject: [PATCH 01/14] MOBILE-341: Add the embedded block as a platform view over the native block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget is the third wrapper of the same kind. The block already exists twice in the native SDKs, and a Dart implementation over a WebView plugin would have to reproduce the resolver with its cache, the page contract, the waiting budget, the place-requested event and the session reset — and then keep up with them release after release. So Flutter takes the native container whole and only mirrors its decisions: the height follows the visibility the block reports, and the two outcomes reach the host as `onLoad` and `onFail`. The platform interface gains no methods — a block is a view, not a call — only the view type, the per-view channel name and the report the native side sends up. The widget claims horizontal drags in the gesture arena, because Flutter has no parent to ask not to intercept them, and a carousel inside a list needs them. iOS only for now: on other platforms the widget holds its height and draws nothing. --- mindbox/lib/mindbox.dart | 1 + mindbox/lib/src/embedded_block.dart | 169 ++++++++++++++++++ .../Sources/mindbox_ios/Constants.swift | 4 + .../EmbeddedBlockPlatformView.swift | 142 +++++++++++++++ .../mindbox_ios/MindboxIosPlugin.swift | 4 + .../lib/mindbox_platform_interface.dart | 1 + .../lib/src/embedded_block.dart | 86 +++++++++ 7 files changed, 407 insertions(+) create mode 100644 mindbox/lib/src/embedded_block.dart create mode 100644 mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift create mode 100644 mindbox_platform_interface/lib/src/embedded_block.dart 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..b28b7a8 --- /dev/null +++ b/mindbox/lib/src/embedded_block.dart @@ -0,0 +1,169 @@ +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, +/// ) +/// ``` +/// +/// The widget is a thin layer over the native block: the platform view holds the SDK's own container +/// — with its placeholder, its waiting budget and its web page — and this widget only mirrors the +/// container's decisions in the Flutter layout. +class MindboxEmbeddedBlock extends StatefulWidget { + const MindboxEmbeddedBlock({ + Key? key, + required this.placeSystemName, + required this.height, + 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. + final String placeSystemName; + + /// The height the block occupies while it loads and while it is shown. Fixed when the block is + /// created: a new value given to a live block is ignored and reported to the log. + final double height; + + /// The content is shown. + 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. + final VoidCallback? onFail; + + @override + State createState() => _MindboxEmbeddedBlockState(); +} + +class _MindboxEmbeddedBlockState extends State { + /// Fixed at creation, like in the SwiftUI and Compose wrappers: the native block is built with a + /// height, and re-creating it on every new value would reload the web page — which is what a + /// `GeometryReader` or a height animation would otherwise do on every frame. + late final double _height = widget.height; + + /// Starts where the native container starts: the space is taken and the placeholder is up. The + /// block occupies its height right away, not from the container's first report. + bool _isVisible = true; + + EmbeddedBlockOutcome? _deliveredOutcome; + + bool _hasWarnedAboutHeight = false; + + MethodChannel? _channel; + + @override + void didUpdateWidget(covariant MindboxEmbeddedBlock oldWidget) { + super.didUpdateWidget(oldWidget); + _warnIfHeightIsIgnored(); + } + + @override + void dispose() { + _channel?.setMethodCallHandler(null); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + height: _isVisible ? _height : 0, + child: _nativeBlock(), + ); + } + + Widget _nativeBlock() { + if (defaultTargetPlatform != TargetPlatform.iOS) { + // Only iOS registers the platform view so far. The place still holds its height, so a layout + // built around the block does not jump when the other platform catches up. + return const SizedBox.shrink(); + } + + return KeyedSubtree( + // A different place is a different block: the platform view is recreated rather than + // repointed, the same way `key(placeSystemName)` works in Compose and `.id(…)` in SwiftUI. + key: ValueKey(widget.placeSystemName), + child: UiKitView( + viewType: embeddedBlockViewType, + creationParams: { + EmbeddedBlockParams.placeSystemName: widget.placeSystemName, + EmbeddedBlockParams.height: _height, + }, + creationParamsCodec: const StandardMessageCodec(), + // A block is typically a horizontal carousel inside a vertical scroll. Flutter has no + // parent to ask not to intercept touches — the gesture arena decides — so the platform view + // has to claim horizontal drags itself, or the surrounding list takes them first. + gestureRecognizers: >{ + Factory( + () => HorizontalDragGestureRecognizer(), + ), + }, + onPlatformViewCreated: _listenTo, + ), + ); + } + + void _listenTo(int viewId) { + final MethodChannel channel = MethodChannel(embeddedBlockChannelName(viewId)); + channel.setMethodCallHandler(_handle); + _channel = channel; + } + + Future _handle(MethodCall call) async { + if (call.method != EmbeddedBlockMethods.report) { + return; + } + + final EmbeddedBlockReport? report = EmbeddedBlockReport.tryParse(call.arguments); + if (report == null || !mounted) { + return; + } + + if (report.isVisible != _isVisible) { + setState(() => _isVisible = report.isVisible); + } + + _deliver(report.outcome); + } + + /// The native side reports where the block stands, not what changed, so the same outcome can + /// arrive more than once — the host must hear it exactly once. + void _deliver(EmbeddedBlockOutcome? outcome) { + if (outcome == null || outcome == _deliveredOutcome) { + return; + } + + _deliveredOutcome = outcome; + if (outcome == EmbeddedBlockOutcome.load) { + widget.onLoad?.call(); + } else { + widget.onFail?.call(); + } + } + + void _warnIfHeightIsIgnored() { + if (_hasWarnedAboutHeight || widget.height == _height) { + return; + } + + _hasWarnedAboutHeight = true; + debugPrint( + '[MindboxEmbeddedBlock] Block "${widget.placeSystemName}" was given height ${widget.height} ' + 'after creation and keeps $_height: the height is fixed when the block is created.', + ); + } +} 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..39fb297 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,8 @@ import Foundation enum Constants { static let pluginChannelName = "mindbox.cloud/flutter-sdk"; + + /// Matches `embeddedBlockViewType` in `mindbox_platform_interface`: the Dart widget asks for the + /// platform view by this name, so the two spellings cannot drift apart. + 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..d58afc9 --- /dev/null +++ b/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift @@ -0,0 +1,142 @@ +import Flutter +import UIKit +@_spi(Internal) import Mindbox +import MindboxLogger + +/// Builds the native embedded block for a Flutter platform view. +/// +/// The block itself is the SDK's `MindboxEmbeddedBlockView`, whole and unchanged: the resolver, the +/// waiting budget, the page and its bridge stay on the native side, and Flutter gets a view to place +/// plus two signals to react to. A Dart implementation over a WebView plugin would have to reproduce +/// all of that and then keep up with it release after release. +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) + } +} + +/// One block on a Flutter screen: the native container plus the channel it reports through. +final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { + + private let blockView: MindboxEmbeddedBlockView + private let channel: FlutterMethodChannel + + /// The last pair sent up. Kept because the two signals arrive separately while Dart needs them + /// together: the visibility observer fires inside the container's state change, the outcome on + /// the next turn of the main queue — so each message carries the whole picture, not a delta. + private var isVisible = true + 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 + + blockView = MindboxEmbeddedBlockView(placeSystemName: placeSystemName, + height: CGFloat(height)) + 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) + } + + blockView.delegate = self + blockView.setVisibilityObserver { [weak self] isVisible in + self?.report(isVisible: isVisible) + } + channel.setMethodCallHandler { [weak self] call, result in + self?.handle(call, result: result) + } + } + + deinit { + // The platform view is gone, so the block's screen is gone with it. Waiting for the last + // reference to go instead would keep a page loading for a screen nobody can see. + blockView.release() + channel.setMethodCallHandler(nil) + } + + func view() -> UIView { + blockView + } + + private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + 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) + default: + result(FlutterMethodNotImplemented) + } + } + + private func report(isVisible: Bool) { + self.isVisible = isVisible + send() + } + + private func report(outcome: String) { + self.outcome = outcome + send() + } + + private func send() { + // No outcome key while there is no outcome: a nil inside the dictionary would have to survive + // the standard codec, and "the key is absent" says the same thing without relying on that. + var arguments: [String: Any] = [Keys.isVisible: isVisible] + 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 report = "report" + static let setHostVisible = "setHostVisible" + static let isVisible = "isVisible" + static let outcome = "outcome" + static let load = "load" + static let fail = "fail" + } +} + +// 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..4ba7740 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,10 @@ public class MindboxIosPlugin: NSObject, FlutterPlugin { registrar.addMethodCallDelegate(instance, channel: channel) registrar.addApplicationDelegate(instance) + // The embedded block is a view, not a call: it gets a platform view factory instead of a + // method on the plugin channel, and talks over a channel of its own per created block. + registrar.register(EmbeddedBlockPlatformViewFactory(messenger: registrar.messenger()), + withId: Constants.embeddedBlockViewType) } init(channel: FlutterMethodChannel) { 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..594807c --- /dev/null +++ b/mindbox_platform_interface/lib/src/embedded_block.dart @@ -0,0 +1,86 @@ +/// 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 — whether it occupies space 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._(); + + static const String placeSystemName = 'placeSystemName'; + static const String height = 'height'; +} + +/// 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: whether the host still shows the block. + static const String setHostVisible = 'setHostVisible'; +} + +/// 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 { load, fail } + +/// What the native block says about itself. +class EmbeddedBlockReport { + const EmbeddedBlockReport({required this.isVisible, this.outcome}); + + /// Whether the block occupies space. `false` — it collapsed, and the space is the host's again. + /// + /// The native container decides this: the rules for a placeholder, an opted-in error screen and + /// an empty place all live there, and Dart only mirrors the answer in its layout. + final bool isVisible; + + /// How the load ended, or `null` while it has not ended. + 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 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; + } + + final Object? isVisible = arguments[_isVisibleKey]; + if (isVisible is! bool) { + return null; + } + + return EmbeddedBlockReport( + isVisible: isVisible, + outcome: _outcomeOf(arguments[_outcomeKey]), + ); + } + + static EmbeddedBlockOutcome? _outcomeOf(Object? raw) { + if (raw == _loadOutcome) { + return EmbeddedBlockOutcome.load; + } + if (raw == _failOutcome) { + return EmbeddedBlockOutcome.fail; + } + return null; + } + + static const String _isVisibleKey = 'isVisible'; + static const String _outcomeKey = 'outcome'; + static const String _loadOutcome = 'load'; + static const String _failOutcome = 'fail'; +} From 0d2355d834282bca97cd09be9c5638290a0a52bb Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 18 Aug 2026 20:06:32 +0500 Subject: [PATCH 02/14] MOBILE-341: Run the embedded block on Android as well as iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget had no Android side at all: it reported the platform unsupported, collapsed and called onFail. The native block exists on both platforms with the same wrapper hooks, so what was missing was the platform view around it — and one Dart branch deciding which platform view class to build. Everything else in the widget is written once. The container keeps the rules and answers with an appearance; Dart mirrors it in the layout and draws the host's own placeholder and failure screens over the platform view. Neither screen can be handed to the native side — a Flutter widget has no view behind it — so the container is told only that the place is taken, through a transparent stand-in, and Flutter draws what actually goes there. The channel gains a sync request, and it is not a convenience. The container hands out its appearance the moment the wrapper subscribes, which happens while the platform view is being built, before Dart can install its handler. A place with nothing behind it settles synchronously right there, so its only report went to a channel nobody was on yet and the widget waited out its whole life on a loading screen for a block that had already given its space back. Dart now asks where the block stands as soon as it can listen, and both native sides answer. --- mindbox/lib/src/embedded_block.dart | 324 +++++++++++++++--- .../EmbeddedBlockPlatformView.kt | 229 +++++++++++++ .../mindbox_android/MindboxAndroidPlugin.kt | 6 + .../EmbeddedBlockPlatformView.swift | 95 ++++- .../lib/src/embedded_block.dart | 91 ++++- 5 files changed, 681 insertions(+), 64 deletions(-) create mode 100644 mindbox_android/android/src/main/kotlin/cloud/mindbox/mindbox_android/EmbeddedBlockPlatformView.kt diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index b28b7a8..bde42a1 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; @@ -18,14 +20,35 @@ import 'package:mindbox_platform_interface/mindbox_platform_interface.dart'; /// ) /// ``` /// +/// 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(), +/// ) +/// ``` +/// /// The widget is a thin layer over the native block: the platform view holds the SDK's own container -/// — with its placeholder, its waiting budget and its web page — and this widget only mirrors the -/// container's decisions in the Flutter layout. -class MindboxEmbeddedBlock extends StatefulWidget { +/// — 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.placeholder, + this.errorBuilder, this.onLoad, this.onFail, }) : super(key: key); @@ -36,8 +59,28 @@ class MindboxEmbeddedBlock extends StatefulWidget { /// The height the block occupies while it loads and while it is shown. Fixed when the block is /// created: a new value given to a live block is ignored and reported to the log. + /// + /// To resize a block that is already on screen, give the widget a new [Key] — that is a new block, + /// built from scratch, and it reloads its content. final double height; + /// 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 the next + /// load. Passing it from the start is what a host that wants a failure screen should do. + final WidgetBuilder? errorBuilder; + /// The content is shown. final VoidCallback? onLoad; @@ -46,18 +89,57 @@ class MindboxEmbeddedBlock extends StatefulWidget { final VoidCallback? onFail; @override - State createState() => _MindboxEmbeddedBlockState(); + Widget build(BuildContext context) { + return _EmbeddedBlock( + // A different place is a different block, and everything remembered about the old one has to + // go with it — the outcome already delivered, the appearance last shown, the height fixed at + // creation. Keying the state and not just the platform view is what `.id(placeSystemName)` + // does in SwiftUI; keying only the view would keep a live State pointing at a dead block. + key: ValueKey(placeSystemName), + placeSystemName: placeSystemName, + height: height, + placeholder: placeholder, + errorBuilder: errorBuilder, + onLoad: onLoad, + onFail: onFail, + ); + } +} + +class _EmbeddedBlock extends StatefulWidget { + const _EmbeddedBlock({ + Key? key, + required this.placeSystemName, + required this.height, + required this.placeholder, + required this.errorBuilder, + required this.onLoad, + required this.onFail, + }) : super(key: key); + + final String placeSystemName; + final double height; + final WidgetBuilder? placeholder; + final WidgetBuilder? errorBuilder; + final VoidCallback? onLoad; + final VoidCallback? onFail; + + @override + State<_EmbeddedBlock> createState() => _EmbeddedBlockState(); } -class _MindboxEmbeddedBlockState extends State { - /// Fixed at creation, like in the SwiftUI and Compose wrappers: the native block is built with a - /// height, and re-creating it on every new value would reload the web page — which is what a - /// `GeometryReader` or a height animation would otherwise do on every frame. - late final double _height = widget.height; +class _EmbeddedBlockState extends State<_EmbeddedBlock> { + /// The height as given, kept to tell an ignored new value from the one the block was built with. + late final double _creationHeight = widget.height; + + /// The height as laid out. Clamped like the native container and the SwiftUI wrapper do — a + /// negative height computed from a `MediaQuery` reaches the block as a broken constraint here, + /// while on the native side it is only a log line and an invisible block. + late final double _height = math.max(0, _creationHeight); - /// Starts where the native container starts: the space is taken and the placeholder is up. The + /// Starts where the native container starts: the space is taken and the loading screen is up. The /// block occupies its height right away, not from the container's first report. - bool _isVisible = true; + EmbeddedBlockAppearance _appearance = EmbeddedBlockAppearance.placeholder; EmbeddedBlockOutcome? _deliveredOutcome; @@ -65,10 +147,64 @@ class _MindboxEmbeddedBlockState extends State { MethodChannel? _channel; + /// What the native side was last *told*, not what the widget last held. + /// + /// The difference is the whole point: a change that happens before the platform view exists has + /// nowhere to go, and comparing against the previous widget would call that change delivered and + /// never mention it again. Compared against this, an undelivered change stays pending until the + /// channel appears. + bool? _syncedHasPlaceholder; + bool? _syncedHasErrorView; + bool? _syncedHostVisible; + + bool _isHostVisible = true; + + bool get _hasPlaceholder => widget.placeholder != null; + + bool get _hasErrorView => widget.errorBuilder != null; + + /// The platforms that have a native block behind the widget. Both wrap the very same container, + /// speak the same channel and answer with the same appearances — that is the whole point of the + /// arrangement, and it is why the widget itself needs no per-platform branch beyond which platform + /// view class to build. + static bool get _isSupported => + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android; + + @override + void initState() { + super.initState(); + if (!_isSupported) { + // No platform view means no reports and no outcome — and a host told to drop its section in + // `onFail` would keep an empty hole forever waiting for one. Answer the way an empty place + // answers, so the layout around the block behaves the same on every platform. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + setState(() => _appearance = EmbeddedBlockAppearance.collapsed); + _deliver(EmbeddedBlockOutcome.fail); + }); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // `Overlay` turns tickers off for a route covered by an opaque one, which is exactly when a + // Flutter screen stops being seen while its platform view stays in the window. + // `valuesOf` is the non-deprecated spelling, but it is newer than the Flutter floor this + // package declares, and `of` says everything the block needs. + // ignore: deprecated_member_use + _isHostVisible = TickerMode.of(context); + _pushHostVisible(); + } + @override - void didUpdateWidget(covariant MindboxEmbeddedBlock oldWidget) { + void didUpdateWidget(covariant _EmbeddedBlock oldWidget) { super.didUpdateWidget(oldWidget); _warnIfHeightIsIgnored(); + _pushStandIns(); } @override @@ -79,41 +215,85 @@ class _MindboxEmbeddedBlockState extends State { @override Widget build(BuildContext context) { + final Widget? hostLayer = _hostLayer(context); + return SizedBox( - width: double.infinity, - height: _isVisible ? _height : 0, - child: _nativeBlock(), + height: _appearance == EmbeddedBlockAppearance.collapsed ? 0 : _height, + child: Stack( + fit: StackFit.expand, + children: [ + _nativeBlock(), + // Nothing to draw is no child at all. An empty widget would be harmless to touches — it + // hit-tests to nothing and the block underneath still hears them — but it is a layer the + // engine has to composite over the platform view for no reason at all. + if (hostLayer != null) hostLayer, + ], + ), ); } + /// The host's own screen for the current appearance, or `null` when the host draws nothing. + 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 (defaultTargetPlatform != TargetPlatform.iOS) { - // Only iOS registers the platform view so far. The place still holds its height, so a layout - // built around the block does not jump when the other platform catches up. + if (!_isSupported) { return const SizedBox.shrink(); } - return KeyedSubtree( - // A different place is a different block: the platform view is recreated rather than - // repointed, the same way `key(placeSystemName)` works in Compose and `.id(…)` in SwiftUI. - key: ValueKey(widget.placeSystemName), - child: UiKitView( + final Map creationParams = { + EmbeddedBlockParams.placeSystemName: widget.placeSystemName, + EmbeddedBlockParams.height: _height, + // The container is told that the place is taken, not what goes into it: it holds back its + // shimmer and keeps a failed block standing, and Dart draws the screen itself. + EmbeddedBlockParams.hasPlaceholder: _hasPlaceholder, + EmbeddedBlockParams.hasErrorView: _hasErrorView, + }; + + // A block is typically a horizontal carousel inside a vertical scroll. Flutter has no parent to + // ask not to intercept touches — the gesture arena decides — so the platform view has to claim + // horizontal drags itself, or the surrounding list takes them first. + // + // Only while the content is what is on screen. Under a host's own screen the block has nothing + // to scroll, and claiming drags there would take them from a placeholder or a failure screen + // that scrolls or swipes on its own. + final Set> gestureRecognizers = + _appearance == EmbeddedBlockAppearance.content + ? >{ + Factory( + () => HorizontalDragGestureRecognizer(), + ), + } + : const >{}; + + // The only place in the widget that knows which platform it is on. Everything else — the layers, + // the height, the outcome, the two signals sent down — is written once and reads the same answer + // from either native side. + if (defaultTargetPlatform == TargetPlatform.android) { + return AndroidView( viewType: embeddedBlockViewType, - creationParams: { - EmbeddedBlockParams.placeSystemName: widget.placeSystemName, - EmbeddedBlockParams.height: _height, - }, + creationParams: creationParams, creationParamsCodec: const StandardMessageCodec(), - // A block is typically a horizontal carousel inside a vertical scroll. Flutter has no - // parent to ask not to intercept touches — the gesture arena decides — so the platform view - // has to claim horizontal drags itself, or the surrounding list takes them first. - gestureRecognizers: >{ - Factory( - () => HorizontalDragGestureRecognizer(), - ), - }, + gestureRecognizers: gestureRecognizers, onPlatformViewCreated: _listenTo, - ), + ); + } + + return UiKitView( + viewType: embeddedBlockViewType, + creationParams: creationParams, + creationParamsCodec: const StandardMessageCodec(), + gestureRecognizers: gestureRecognizers, + onPlatformViewCreated: _listenTo, ); } @@ -121,6 +301,17 @@ class _MindboxEmbeddedBlockState extends State { final MethodChannel channel = MethodChannel(embeddedBlockChannelName(viewId)); channel.setMethodCallHandler(_handle); _channel = channel; + // Where does the block stand? Asked rather than assumed: the container hands out its appearance + // the moment the native wrapper subscribes, which is while the platform view is being built — + // before this handler existed. A place with nothing behind it settles right there, and its only + // report would be lost, leaving the widget on a loading screen for a block that already gave its + // space back. + _invoke(channel, EmbeddedBlockMethods.sync, null); + // Everything the block was told before it existed is told now. The platform view is created a + // few frames after the first build, and a host that gains a failure screen — or leaves the + // screen — inside that window would otherwise be heard by nobody, permanently. + _pushStandIns(); + _pushHostVisible(); } Future _handle(MethodCall call) async { @@ -133,8 +324,9 @@ class _MindboxEmbeddedBlockState extends State { return; } - if (report.isVisible != _isVisible) { - setState(() => _isVisible = report.isVisible); + final EmbeddedBlockAppearance? appearance = report.appearance; + if (appearance != null && appearance != _appearance) { + setState(() => _appearance = appearance); } _deliver(report.outcome); @@ -155,15 +347,67 @@ class _MindboxEmbeddedBlockState extends State { } } + /// Whether the host draws its own screens can change between builds — a placeholder given only + /// while a feature flag is on, a failure screen added once the section knows it can retry. + /// + /// Only the answer travels, not the builder: a widget rebuilt with a different closure that still + /// draws a placeholder is the same answer, and telling the container about it on every frame would + /// make it swap its layers for nothing. + 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, + }, + ); + } + + /// Tells the block whether the screen it stands on is still the one being looked at. + /// + /// The native container watches its window, and in Flutter that is not enough: every screen shares + /// one window, so pushing a route over the block never takes it out. Left alone, the block would + /// spend its whole waiting budget behind another screen and collapse before the user came back to + /// a place that never gets its space again. + void _pushHostVisible() { + final MethodChannel? channel = _channel; + if (channel == null || _syncedHostVisible == _isHostVisible) { + return; + } + + _syncedHostVisible = _isHostVisible; + _invoke(channel, EmbeddedBlockMethods.setHostVisible, _isHostVisible); + } + + /// Sends and forgets, but does not leave the failure unhandled: a call into a platform view the + /// engine has already disposed answers with a `MissingPluginException`, and an uncaught one + /// surfaces to the host as a crash report for a block that is simply gone. + 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 _warnIfHeightIsIgnored() { - if (_hasWarnedAboutHeight || widget.height == _height) { + if (_hasWarnedAboutHeight || widget.height == _creationHeight) { return; } _hasWarnedAboutHeight = true; debugPrint( '[MindboxEmbeddedBlock] Block "${widget.placeSystemName}" was given height ${widget.height} ' - 'after creation and keeps $_height: the height is fixed when the block is created.', + 'after creation and keeps $_creationHeight: the height is fixed when the block is created. ' + 'Give the widget a new Key to build a block of a different height.', ); } } 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..676f5fe --- /dev/null +++ b/mindbox_android/android/src/main/kotlin/cloud/mindbox/mindbox_android/EmbeddedBlockPlatformView.kt @@ -0,0 +1,229 @@ +package cloud.mindbox.mindbox_android + +import android.content.Context +import android.graphics.Color +import android.view.View +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 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 + +/** + * Builds the native embedded block for a Flutter platform view. + * + * The block itself is the SDK's `MindboxEmbeddedBlockView`, whole and unchanged: the content + * factory, the waiting budget, the page and its bridge stay on the native side, and Flutter gets a + * view to place plus the signals to react to. A Dart implementation over a WebView plugin would have + * to reproduce all of that and then keep up with it release after release. + */ +@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) +} + +/** One block on a Flutter screen: the native container plus the channel it reports through. */ +@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 + + /** + * The last pair sent up. Kept because the two signals arrive separately while Dart needs them + * together: the appearance observer fires inside the container's state change, the outcome on the + * next turn of the main looper — so each message carries the whole picture, not a delta. + */ + private var appearance = PLACEHOLDER + private var outcome: String? = null + + /** + * The stand-ins currently handed to the container, kept to tell "the host still draws its own + * screen" from "it has just started to". Declared above `init`, which sets them: a property + * initializer further down the class would run afterwards and put the null back. + */ + 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 ?: "" + + // The height is not passed on: on Android the block is a frame sized by its parent, and here + // that parent is Flutter — the platform view is laid out to the height Dart gives it. + blockView = MindboxEmbeddedBlockView(context, placeSystemName) + channel = MethodChannel(messenger, "$VIEW_TYPE/$viewId") + + 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) + }, + ) + // Last, and after the handler is in place: subscribing hands out the current appearance right + // away, and an empty place answers synchronously while the block attaches. + blockView.setAppearanceObserver { appearance -> report(appearance) } + } + + override fun getView(): View = blockView + + override fun dispose() { + // The platform view is gone, so the block's screen is gone with it. Waiting for the host + // Activity to be destroyed instead would keep a page loading for a screen nobody can see. + blockView.setAppearanceObserver(null) + blockView.setListener(null) + blockView.release() + channel.setMethodCallHandler(null) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + METHOD_SYNC -> { + // Dart has its handler up now and asks where the block stands. Everything reported + // before this point went to a channel nobody was listening on yet. + 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) + } + else -> result.notImplemented() + } + } + + /** + * Puts an empty view where the host draws its own screen — the same arrangement the Compose + * wrapper uses for a slot it cannot hand over directly. + * + * A Flutter widget cannot become an Android View, so the container is not given the screen: it is + * given the fact that the place is taken. That is all it needs — a placeholder of its own is held + * back, and a failed block keeps its height instead of collapsing. What is actually drawn in that + * space is a widget, laid out by Flutter over the platform view. + */ + private fun syncStandIns(hasPlaceholder: Boolean, hasErrorView: Boolean) { + // Assigned only on a change: the container swaps its shown layer on every new view, and a + // fresh stand-in on every Dart rebuild would swap it for an identical one. + 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) + // The stand-in is a placeholder for space, not for touches: what the host drew over it is a + // widget, and it is Flutter that has to hear the taps on it. + 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() { + // No outcome key while there is no outcome: a null inside the map would have to survive the + // standard codec, and "the key is absent" says the same thing without relying on that. + 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_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 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" + + /** + * Spelled out rather than taken from the enum name: the wire word is a contract with the Dart + * side, and renaming a case in the SDK must not quietly change it. + */ + fun nameOf(appearance: MindboxEmbeddedBlockAppearance): String = when (appearance) { + MindboxEmbeddedBlockAppearance.PLACEHOLDER -> PLACEHOLDER + MindboxEmbeddedBlockAppearance.CONTENT -> CONTENT + MindboxEmbeddedBlockAppearance.ERROR -> ERROR + MindboxEmbeddedBlockAppearance.COLLAPSED -> COLLAPSED + } + } +} + +/** The type both native factories register the block under — must match the Dart constant. */ +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..0fccd0f 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,12 @@ class MindboxAndroidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, Ne override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { channel = MethodChannel(flutterPluginBinding.binaryMessenger, "mindbox.cloud/flutter-sdk") channel.setMethodCallHandler(this) + // Registered on the engine and not on the Activity: a block is a view a Dart widget asks for, + // and the widget may be built before this plugin ever sees an Activity. + flutterPluginBinding.platformViewRegistry.registerViewFactory( + EMBEDDED_BLOCK_VIEW_TYPE, + EmbeddedBlockPlatformViewFactory(flutterPluginBinding.binaryMessenger), + ) } override fun onMethodCall(call: MethodCall, result: Result) { diff --git a/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift b/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift index d58afc9..8375633 100644 --- a/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift +++ b/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift @@ -7,7 +7,7 @@ import MindboxLogger /// /// The block itself is the SDK's `MindboxEmbeddedBlockView`, whole and unchanged: the resolver, the /// waiting budget, the page and its bridge stay on the native side, and Flutter gets a view to place -/// plus two signals to react to. A Dart implementation over a WebView plugin would have to reproduce +/// plus the signals to react to. A Dart implementation over a WebView plugin would have to reproduce /// all of that and then keep up with it release after release. public final class EmbeddedBlockPlatformViewFactory: NSObject, FlutterPlatformViewFactory { @@ -36,9 +36,9 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { private let channel: FlutterMethodChannel /// The last pair sent up. Kept because the two signals arrive separately while Dart needs them - /// together: the visibility observer fires inside the container's state change, the outcome on + /// together: the appearance observer fires inside the container's state change, the outcome on /// the next turn of the main queue — so each message carries the whole picture, not a delta. - private var isVisible = true + private var appearance = Keys.placeholder private var outcome: String? init(viewId: Int64, arguments: Any?, messenger: FlutterBinaryMessenger) { @@ -58,9 +58,12 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { category: .embeddedBlocks) } + syncStandIns(hasPlaceholder: params?[Keys.hasPlaceholder] as? Bool ?? false, + hasErrorView: params?[Keys.hasErrorView] as? Bool ?? false) + blockView.delegate = self - blockView.setVisibilityObserver { [weak self] isVisible in - self?.report(isVisible: isVisible) + blockView.setAppearanceObserver { [weak self] appearance in + self?.report(appearance: appearance) } channel.setMethodCallHandler { [weak self] call, result in self?.handle(call, result: result) @@ -70,6 +73,8 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { deinit { // The platform view is gone, so the block's screen is gone with it. Waiting for the last // reference to go instead would keep a page loading for a screen nobody can see. + blockView.setAppearanceObserver(nil) + blockView.delegate = nil blockView.release() channel.setMethodCallHandler(nil) } @@ -80,6 +85,11 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { + case Keys.sync: + // Dart has its handler up now and asks where the block stands. Everything reported before + // this point went to a channel nobody was listening on yet. + send() + result(nil) case Keys.setHostVisible: guard let isHostVisible = call.arguments as? Bool else { result(FlutterError(code: "bad_arguments", @@ -90,13 +100,61 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { 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) default: result(FlutterMethodNotImplemented) } } - private func report(isVisible: Bool) { - self.isVisible = isVisible + /// Puts an empty view where the host draws its own screen — the same arrangement the SwiftUI + /// wrapper uses. + /// + /// A Flutter widget cannot become a `UIView`, so the container is not given the screen: it is + /// given the fact that the place is taken. That is all it needs — a placeholder of its own is + /// held back, and a failed block keeps its height instead of collapsing. What is actually drawn + /// in that space is a widget, laid out by Flutter over the platform view. + private func syncStandIns(hasPlaceholder: Bool, hasErrorView: Bool) { + // Assigned only on a change: the container swaps its shown layer on every new view, and + // a fresh stand-in on every Dart rebuild would swap it for an identical one. + 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 + // The stand-in is a placeholder for space, not for touches: what the host drew over it is a + // widget, and it is Flutter that has to hear the taps on it. + standIn.isUserInteractionEnabled = false + return standIn + } + + private func report(appearance: MindboxEmbeddedBlockAppearance) { + self.appearance = Keys.name(of: appearance) send() } @@ -108,7 +166,7 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { private func send() { // No outcome key while there is no outcome: a nil inside the dictionary would have to survive // the standard codec, and "the key is absent" says the same thing without relying on that. - var arguments: [String: Any] = [Keys.isVisible: isVisible] + var arguments: [String: Any] = [Keys.appearance: appearance] if let outcome = outcome { arguments[Keys.outcome] = outcome } @@ -119,12 +177,31 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { private enum Keys { static let placeSystemName = "placeSystemName" static let height = "height" + static let hasPlaceholder = "hasPlaceholder" + static let hasErrorView = "hasErrorView" static let report = "report" + static let sync = "sync" static let setHostVisible = "setHostVisible" - static let isVisible = "isVisible" + static let setStandIns = "setStandIns" + 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" + + /// Spelled out rather than derived from the case name: the wire word is a contract with the + /// Dart side, and renaming a case in the SDK must not quietly change it. + 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 + } + } } } diff --git a/mindbox_platform_interface/lib/src/embedded_block.dart b/mindbox_platform_interface/lib/src/embedded_block.dart index 594807c..4cfed5f 100644 --- a/mindbox_platform_interface/lib/src/embedded_block.dart +++ b/mindbox_platform_interface/lib/src/embedded_block.dart @@ -2,7 +2,7 @@ /// /// 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 — whether it occupies space and how its load ended. +/// 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'; @@ -19,6 +19,19 @@ class EmbeddedBlockParams { static const String placeSystemName = 'placeSystemName'; static const String height = 'height'; + + /// 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. @@ -28,8 +41,46 @@ class 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'; +} + +/// 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 @@ -38,37 +89,39 @@ enum EmbeddedBlockOutcome { load, fail } /// What the native block says about itself. class EmbeddedBlockReport { - const EmbeddedBlockReport({required this.isVisible, this.outcome}); + const EmbeddedBlockReport({this.appearance, this.outcome}); - /// Whether the block occupies space. `false` — it collapsed, and the space is the host's again. + /// What to draw, or `null` when the message carries no answer this version understands. /// - /// The native container decides this: the rules for a placeholder, an opted-in error screen and - /// an empty place all live there, and Dart only mirrors the answer in its layout. - final bool isVisible; + /// 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 this version does - /// not know, and that is no reason to break the block. + /// 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; } - final Object? isVisible = arguments[_isVisibleKey]; - if (isVisible is! bool) { - return null; - } - return EmbeddedBlockReport( - isVisible: isVisible, + 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; @@ -79,7 +132,15 @@ class EmbeddedBlockReport { return null; } - static const String _isVisibleKey = 'isVisible'; + 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'; From 9bb9d5b2131b1c1fdb5863120c6c1b6bc609a417 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 24 Aug 2026 14:57:36 +0500 Subject: [PATCH 03/14] MOBILE-341: Cover the embedded block with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget draws a platform view on iOS and Android, and a widget test has neither. What is left is the half written once, and that is what is checked: the layout the block hands back, the screens the host draws over it, and the outcome the host hears. Forcing the platform override is what reaches that half — the unsupported path answers the way an empty place answers, so the same expectations hold. The interface tests cover the wire instead: a channel of its own per view, the view type both native factories register under, and every shape a report can arrive in. Including a word a newer native side might send that this version does not know, which has to read as "nothing said about it" rather than throw. --- mindbox/test/embedded_block_test.dart | 142 ++++++++++++++++++ .../test/src/embedded_block_test.dart | 80 ++++++++++ 2 files changed, 222 insertions(+) create mode 100644 mindbox/test/embedded_block_test.dart create mode 100644 mindbox_platform_interface/test/src/embedded_block_test.dart diff --git a/mindbox/test/embedded_block_test.dart b/mindbox/test/embedded_block_test.dart new file mode 100644 index 0000000..dbdfe29 --- /dev/null +++ b/mindbox/test/embedded_block_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mindbox/mindbox.dart'; + +/// Runs [body] on a platform the block has no native half for. +/// +/// The override is cleared inside the test rather than in a `tearDown`: `flutter_test` checks the +/// foundation debug variables on the way out of the body, before any teardown runs. + +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() { + // The widget draws a platform view on iOS and Android, and neither exists in a widget test. What + // is checked here is the part written once and read the same on every platform: the layout the + // block hands back, the screens the host draws, and the outcome it hears. + 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++, + ), + ), + )); + + // The space is taken before anything is known about the place. + expect(tester.getSize(find.byType(MindboxEmbeddedBlock)).height, 104); + + await tester.pump(); + + // And handed back once it turns out there is no block behind it. + 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); + + // Everything remembered about the old block goes with it: the new one reports its own outcome. + await buildFor('promo'); + await tester.pump(); + expect(fails, 2); + }); + }); +} 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..895e1af --- /dev/null +++ b/mindbox_platform_interface/test/src/embedded_block_test.dart @@ -0,0 +1,80 @@ +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); + }); + // Every case is covered, so a new appearance cannot be added without this failing. + 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}, + ); + + // Parsed, not rejected: an unknown word leaves the host on what it already knew. + 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); + }); + }); +} From 80a08243f116bbd377b57ccf9e9a88cd7763e70b Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 24 Aug 2026 14:57:48 +0500 Subject: [PATCH 04/14] MOBILE-341: Document the embedded block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README gets the section an integrator starts from: the place is named and the SDK decides what goes into it, the host owns the height, and a place that ends up without content gives its space back. Both hooks are shown the way SwiftUI and Compose show them, with onFail wired to what a host actually does with it — drop its own section — and the empty-place rule spelled out, because that is the one place where errorBuilder deliberately does not apply. The interface constants and the outcome cases get the dartdoc they were missing, so the API reads without opening the native side: which of the two params the iOS factory reads and why Android ignores the height, and what each of the two outcomes means. Four CHANGELOGs, because all four packages move together. --- mindbox/CHANGELOG.md | 4 +++ mindbox/README.md | 32 +++++++++++++++++++ mindbox_android/CHANGELOG.md | 4 +++ mindbox_ios/CHANGELOG.md | 4 +++ mindbox_platform_interface/CHANGELOG.md | 4 +++ .../lib/src/embedded_block.dart | 14 +++++++- 6 files changed, 61 insertions(+), 1 deletion(-) 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..e41ecfe 100644 --- a/mindbox/README.md +++ b/mindbox/README.md @@ -32,6 +32,38 @@ 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), +) +``` + +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_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_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_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/src/embedded_block.dart b/mindbox_platform_interface/lib/src/embedded_block.dart index 4cfed5f..47640be 100644 --- a/mindbox_platform_interface/lib/src/embedded_block.dart +++ b/mindbox_platform_interface/lib/src/embedded_block.dart @@ -17,7 +17,11 @@ String embeddedBlockChannelName(int viewId) => '$embeddedBlockViewType/$viewId'; 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'; /// Whether the host draws a loading screen of its own. @@ -85,10 +89,18 @@ enum EmbeddedBlockAppearance { /// 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 { load, fail } +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. From 1849302d4b4a1614a46c6d49d5fddc9ac5c4299f Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 24 Aug 2026 16:33:40 +0500 Subject: [PATCH 05/14] MOBILE-341: Fix what the review found in the embedded block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WidgetsBinding.instance` reads as non-nullable only from Flutter 3, and every package here still declares a 2.0 floor — the interface keeps a null check on `ServicesBinding.instance` for exactly that reason. `ensureInitialized` returns the binding on both sides of the change, and inside a widget it initializes nothing: the binding is long up by the time one builds. A platform view can be built again for the same `State`. What had been sent to the previous one stayed recorded as sent, so the resend on a new view said nothing — and a block whose host is already hidden would leave the new container, which starts out believing it is on screen, to spend its whole waiting budget behind a covered route. The record now goes with the view it describes. A place name that never arrives is reported on Android the way it already is on iOS. The container does warn about a place it cannot resolve, but in the words of the XML attribute it was written for, which names nothing a Flutter host can set. And the iOS teardown drops the delegate through `release()` rather than before it: the block marks itself released first, so the `didSet` no longer reads the change as a new subscriber and schedules a delivery for a view being torn down. --- mindbox/lib/src/embedded_block.dart | 16 +++++++++++++++- .../mindbox_android/EmbeddedBlockPlatformView.kt | 13 +++++++++++++ .../mindbox_ios/EmbeddedBlockPlatformView.swift | 7 +++++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index bde42a1..cd71fa1 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -178,7 +178,11 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { // No platform view means no reports and no outcome — and a host told to drop its section in // `onFail` would keep an empty hole forever waiting for one. Answer the way an empty place // answers, so the layout around the block behaves the same on every platform. - WidgetsBinding.instance.addPostFrameCallback((_) { + // `WidgetsBinding.instance` reads as non-nullable only from Flutter 3, and this package still + // declares a 2.0 floor. `ensureInitialized` returns the binding itself on both — inside a + // widget it is long up, so nothing is initialized here: this is the same instance, spelled in + // a way that compiles either side of the change. + WidgetsFlutterBinding.ensureInitialized().addPostFrameCallback((_) { if (!mounted) { return; } @@ -298,6 +302,16 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { } void _listenTo(int viewId) { + // A platform view can be built again for the same `State` — a new id, a new native container + // that has heard nothing. What was sent to the previous one is not what this one knows, and + // left in place it would silence the resend below: a block whose host is already hidden would + // match the answer it sent the old view, say nothing, and let the new one — which starts out + // believing it is on screen — spend its whole waiting budget behind a covered route. + _channel?.setMethodCallHandler(null); + _syncedHasPlaceholder = null; + _syncedHasErrorView = null; + _syncedHostVisible = null; + final MethodChannel channel = MethodChannel(embeddedBlockChannelName(viewId)); channel.setMethodCallHandler(_handle); _channel = channel; 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 index 676f5fe..f259590 100644 --- 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 @@ -3,10 +3,12 @@ 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 @@ -68,6 +70,17 @@ internal class EmbeddedBlockPlatformView( blockView = MindboxEmbeddedBlockView(context, placeSystemName) channel = MethodChannel(messenger, "$VIEW_TYPE/$viewId") + // Said here rather than left to the container: it does warn about a place it cannot resolve, + // but in the words of the XML attribute it was written for, which names nothing a Flutter + // host can set. The same mistake is reported the same way on both platforms. + 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, diff --git a/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift b/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift index 8375633..959449f 100644 --- a/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift +++ b/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift @@ -73,8 +73,11 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { deinit { // The platform view is gone, so the block's screen is gone with it. Waiting for the last // reference to go instead would keep a page loading for a screen nobody can see. - blockView.setAppearanceObserver(nil) - blockView.delegate = nil + // + // `release()` and nothing else: it marks the block released before dropping the delegate and + // the observer itself. Dropping the delegate here first would do it while the block still + // counts as live, and its `didSet` would read the change as a new subscriber and schedule a + // delivery on the main queue for a view being torn down. blockView.release() channel.setMethodCallHandler(nil) } From 34054856eff93f5607f6f55a3db541b22c151a74 Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 25 Aug 2026 17:14:15 +0500 Subject: [PATCH 06/14] MOBILE-341: Give the embedded block a timeout the host can set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DoD asks the host to learn about a timeout, and the block had no way to name one: it always got the SDK's own 30 seconds. `timeout` is a `Duration`, and leaving it out still means that default — the key is simply absent from the creation params, which is what tells either native side to keep its own budget. There is no number that means "no budget", so an absent key says it instead of a sentinel. Milliseconds on the wire, because the two containers spell the budget differently — seconds on iOS, milliseconds on Android — and an integer is the one spelling the standard codec carries the same way to both. Fixed when the block is created, exactly as the height is: the value goes down with the platform view and a later one has no container left to reach, so a changed budget is ignored and said once in the log. A non-positive value goes down as it was given: both containers already fall back to their default and write what they were given, and second-guessing that here would put one rule in three places, spelled three ways. The Android container had no programmatic way in until now — that is the companion commit in the Android SDK, and it is why item 11 of the platform differences moves to what is already settled. --- mindbox/README.md | 17 +++ mindbox/lib/src/embedded_block.dart | 74 ++++++++-- mindbox/test/embedded_block_test.dart | 139 ++++++++++++++++++ .../EmbeddedBlockPlatformView.kt | 9 +- .../EmbeddedBlockPlatformView.swift | 8 +- .../lib/src/embedded_block.dart | 8 + 6 files changed, 243 insertions(+), 12 deletions(-) diff --git a/mindbox/README.md b/mindbox/README.md index e41ecfe..1a4294a 100644 --- a/mindbox/README.md +++ b/mindbox/README.md @@ -61,6 +61,23 @@ MindboxEmbeddedBlock( ) ``` +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), +) +``` + +Both `height` and `timeout` are fixed when the block is created — a new value given to a block +already on screen is ignored and reported to the log. Give the widget a new `Key` to build a block +on new terms. + 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. diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index cd71fa1..e2a5ea8 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -35,6 +35,9 @@ import 'package:mindbox_platform_interface/mindbox_platform_interface.dart'; /// ) /// ``` /// +/// 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. @@ -47,6 +50,7 @@ class MindboxEmbeddedBlock extends StatelessWidget { Key? key, required this.placeSystemName, required this.height, + this.timeout, this.placeholder, this.errorBuilder, this.onLoad, @@ -64,6 +68,25 @@ class MindboxEmbeddedBlock extends StatelessWidget { /// built from scratch, and it reloads its content. 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, exactly as [height] is: 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 @@ -98,6 +121,7 @@ class MindboxEmbeddedBlock extends StatelessWidget { key: ValueKey(placeSystemName), placeSystemName: placeSystemName, height: height, + timeout: timeout, placeholder: placeholder, errorBuilder: errorBuilder, onLoad: onLoad, @@ -111,6 +135,7 @@ class _EmbeddedBlock extends StatefulWidget { Key? key, required this.placeSystemName, required this.height, + required this.timeout, required this.placeholder, required this.errorBuilder, required this.onLoad, @@ -119,6 +144,7 @@ class _EmbeddedBlock extends StatefulWidget { final String placeSystemName; final double height; + final Duration? timeout; final WidgetBuilder? placeholder; final WidgetBuilder? errorBuilder; final VoidCallback? onLoad; @@ -137,6 +163,14 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { /// while on the native side it is only a log line and an invisible block. late final double _height = math.max(0, _creationHeight); + /// The budget as given, kept for the same reason the height is: it goes to the native block once, + /// when the platform view is created, and a later value has no container left to reach. + /// + /// Taken in `initState` rather than on first read: on a platform without a native block nothing + /// reads it while the block is being built, and a lazy field would be filled in by the very + /// comparison meant to catch a changed budget — with the changed value. + late final Duration? _creationTimeout; + /// Starts where the native container starts: the space is taken and the loading screen is up. The /// block occupies its height right away, not from the container's first report. EmbeddedBlockAppearance _appearance = EmbeddedBlockAppearance.placeholder; @@ -145,6 +179,8 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { bool _hasWarnedAboutHeight = false; + bool _hasWarnedAboutTimeout = false; + MethodChannel? _channel; /// What the native side was last *told*, not what the widget last held. @@ -174,6 +210,7 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { @override void initState() { super.initState(); + _creationTimeout = widget.timeout; if (!_isSupported) { // No platform view means no reports and no outcome — and a host told to drop its section in // `onFail` would keep an empty hole forever waiting for one. Answer the way an empty place @@ -207,7 +244,7 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { @override void didUpdateWidget(covariant _EmbeddedBlock oldWidget) { super.didUpdateWidget(oldWidget); - _warnIfHeightIsIgnored(); + _warnIfCreationValuesAreIgnored(); _pushStandIns(); } @@ -263,6 +300,13 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { EmbeddedBlockParams.hasErrorView: _hasErrorView, }; + // Sent only when the host named one: an absent key is what tells either native side to keep its + // own default, and there is no number that means "no budget" to put there instead. + final Duration? timeout = _creationTimeout; + if (timeout != null) { + creationParams[EmbeddedBlockParams.timeoutMs] = timeout.inMilliseconds; + } + // A block is typically a horizontal carousel inside a vertical scroll. Flutter has no parent to // ask not to intercept touches — the gesture arena decides — so the platform view has to claim // horizontal drags itself, or the surrounding list takes them first. @@ -412,16 +456,26 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { }); } - void _warnIfHeightIsIgnored() { - if (_hasWarnedAboutHeight || widget.height == _creationHeight) { - return; + /// Both the height and the budget are settled when the block is built and cannot be talked out of + /// it afterwards. Said once per value, and separately: a host that changed only one of them should + /// hear about that one. + void _warnIfCreationValuesAreIgnored() { + if (!_hasWarnedAboutHeight && widget.height != _creationHeight) { + _hasWarnedAboutHeight = true; + debugPrint( + '[MindboxEmbeddedBlock] Block "${widget.placeSystemName}" was given height ${widget.height} ' + 'after creation and keeps $_creationHeight: the height is fixed when the block is created. ' + 'Give the widget a new Key to build a block of a different height.', + ); } - _hasWarnedAboutHeight = true; - debugPrint( - '[MindboxEmbeddedBlock] Block "${widget.placeSystemName}" was given height ${widget.height} ' - 'after creation and keeps $_creationHeight: the height is fixed when the block is created. ' - 'Give the widget a new Key to build a block of a different height.', - ); + 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 index dbdfe29..e307457 100644 --- a/mindbox/test/embedded_block_test.dart +++ b/mindbox/test/embedded_block_test.dart @@ -1,4 +1,5 @@ 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'; @@ -138,5 +139,143 @@ void main() { 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; + } + + // Once, however many times the host tries: a widget rebuilt every frame would otherwise fill + // the log with the same line. + expect(log.where((String line) => line.contains('timeout')), hasLength(1)); + expect(log.single, contains('"stories"')); + expect(log.single, contains('0:00:05')); + }); + }); + + // What the host asks for has to reach the native container, and that is the one thing a widget + // test can still see of it: the creation params the platform view is built with. + 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; + // The view's own window into the buffer, not the whole buffer: the engine hands over a + // slice, and decoding from byte zero of what it is a slice of reads somebody else's message. + created.add(const StandardMessageCodec().decodeMessage( + params.buffer.asByteData(params.offsetInBytes, params.lengthInBytes), + ) as Map); + // A texture id, which is what the Android controller reads back and casts. iOS ignores the + // answer, so one number serves both. + return 0; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform_views, null); + }); + + /// Builds a block on [platform] — the only two the widget has a native half for — and hands back + /// the params its platform view was created with. + 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); + + // Absent, not zero: there is no number that means "no budget", and a native side that finds + // nothing keeps its own 30 seconds. + 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, + ); + + // Not a budget any block could survive — and not Dart's call: both native containers already + // fall back to their default and log what they were given, and second-guessing that here + // would put the same rule in three places, spelled three ways. + expect(params['timeoutMs'], 0); + }); }); } 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 index f259590..9057c68 100644 --- 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 @@ -65,9 +65,15 @@ internal class EmbeddedBlockPlatformView( val params = arguments as? Map<*, *> val placeSystemName = params?.get(KEY_PLACE_SYSTEM_NAME) as? String ?: "" + // Absent means the host named no budget, and that is exactly the container's own `null`: the + // SDK default. Read as a Number rather than an Int: the standard codec sends a value that + // fits in 32 bits as an Int and a longer one as a Long, and a budget in milliseconds sits + // right where the two meet. + val timeoutMs = (params?.get(KEY_TIMEOUT_MS) as? Number)?.toLong() + // The height is not passed on: on Android the block is a frame sized by its parent, and here // that parent is Flutter — the platform view is laid out to the height Dart gives it. - blockView = MindboxEmbeddedBlockView(context, placeSystemName) + blockView = MindboxEmbeddedBlockView(context, placeSystemName, timeoutMs) channel = MethodChannel(messenger, "$VIEW_TYPE/$viewId") // Said here rather than left to the container: it does warn about a place it cannot resolve, @@ -209,6 +215,7 @@ internal class EmbeddedBlockPlatformView( 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" diff --git a/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift b/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift index 959449f..bb71718 100644 --- a/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift +++ b/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift @@ -45,9 +45,14 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { let params = arguments as? [String: Any] let placeSystemName = params?[Keys.placeSystemName] as? String ?? "" let height = (params?[Keys.height] as? NSNumber)?.doubleValue ?? 0 + // Absent means the host named no budget, and that is exactly the container's own `nil`: the + // SDK default. Milliseconds on the wire, seconds here — the container counts in seconds, and + // an integer is what the standard codec carries the same way from either Dart side. + let timeout = (params?[Keys.timeoutMs] as? NSNumber).map { TimeInterval($0.doubleValue) / 1000 } blockView = MindboxEmbeddedBlockView(placeSystemName: placeSystemName, - height: CGFloat(height)) + height: CGFloat(height), + timeout: timeout) channel = FlutterMethodChannel(name: "\(Constants.embeddedBlockViewType)/\(viewId)", binaryMessenger: messenger) super.init() @@ -180,6 +185,7 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { 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" diff --git a/mindbox_platform_interface/lib/src/embedded_block.dart b/mindbox_platform_interface/lib/src/embedded_block.dart index 47640be..f9fbd01 100644 --- a/mindbox_platform_interface/lib/src/embedded_block.dart +++ b/mindbox_platform_interface/lib/src/embedded_block.dart @@ -24,6 +24,14 @@ class EmbeddedBlockParams { /// 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 From 50180e6618211529fd4a1d63aec18f4d8d01a7d7 Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 25 Aug 2026 22:15:54 +0500 Subject: [PATCH 07/14] MOBILE-341: Collapse the embedded block on the web --- mindbox/lib/src/embedded_block.dart | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index e2a5ea8..442ca4b 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -100,8 +100,9 @@ class MindboxEmbeddedBlock extends StatelessWidget { /// 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 the next - /// load. Passing it from the start is what a host that wants a failure screen should do. + /// 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. @@ -203,9 +204,14 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { /// speak the same channel and answer with the same appearances — that is the whole point of the /// arrangement, and it is why the widget itself needs no per-platform branch beyond which platform /// view class to build. + /// + /// The web is excluded by name: there [defaultTargetPlatform] mirrors the browser's host OS, so + /// without [kIsWeb] a phone's browser would claim a native block no browser can build — instead + /// of collapsing the way every other platform without one does. static bool get _isSupported => - defaultTargetPlatform == TargetPlatform.iOS || - defaultTargetPlatform == TargetPlatform.android; + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android); @override void initState() { From 5c8ef3219eed84a045af1e92e1f3008b7a190709 Mon Sep 17 00:00:00 2001 From: Vailence Date: Wed, 26 Aug 2026 18:14:03 +0500 Subject: [PATCH 08/14] MOBILE-341: Stop the block when the widget goes, not when the last reference does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android's platform view has a dispose hook; iOS has none, so there the block waited for `deinit` — whenever the engine happens to let go of the view. A page kept loading for a screen that no longer exists is what that wait costs. Dart knows the moment exactly on both platforms, so `dispose()` now sends a `release` on the per-view channel before dropping the handler. Both native sides release idempotently, so Android is told something its own `dispose()` does a moment later and nothing happens twice. --- mindbox/lib/src/embedded_block.dart | 9 ++- mindbox/test/embedded_block_test.dart | 70 +++++++++++++++++++ .../EmbeddedBlockPlatformView.kt | 8 +++ .../EmbeddedBlockPlatformView.swift | 7 ++ .../lib/src/embedded_block.dart | 10 +++ 5 files changed, 103 insertions(+), 1 deletion(-) diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index 442ca4b..dcd8e5e 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -256,7 +256,14 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { @override void dispose() { - _channel?.setMethodCallHandler(null); + final MethodChannel? channel = _channel; + if (channel != null) { + // The block's screen is gone, and Dart is the only side that knows it on both platforms: + // Android's platform view has a dispose hook, iOS has none and would wait for the engine to + // let go of the view. Sent before the handler goes, so the native side is still answered. + _invoke(channel, EmbeddedBlockMethods.release, null); + channel.setMethodCallHandler(null); + } super.dispose(); } diff --git a/mindbox/test/embedded_block_test.dart b/mindbox/test/embedded_block_test.dart index e307457..5cb2d69 100644 --- a/mindbox/test/embedded_block_test.dart +++ b/mindbox/test/embedded_block_test.dart @@ -3,6 +3,7 @@ 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'; /// Runs [body] on a platform the block has no native half for. /// @@ -278,4 +279,73 @@ void main() { expect(params['timeoutMs'], 0); }); }); + + // Dart is the only side that knows the widget is gone on both platforms: Android's platform view + // has a dispose hook and iOS has none, where the block would otherwise be stopped by whenever the + // engine happens to let go of the view. + 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; + }, + ); + // A texture id, which is what the Android controller reads back and casts. + return 0; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform_views, null); + }); + + for (final TargetPlatform platform in [ + TargetPlatform.iOS, + TargetPlatform.android, + ]) { + final String name = platform == TargetPlatform.iOS ? 'iOS' : 'Android'; + + testWidgets('A disposed widget tells the $name block to stop', + (WidgetTester tester) 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(); + + expect(methods.last, EmbeddedBlockMethods.release); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + } + }); } 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 index 9057c68..c5f4d25 100644 --- 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 @@ -148,6 +148,13 @@ internal class EmbeddedBlockPlatformView( syncStandIns(hasPlaceholder = hasPlaceholder, hasErrorView = hasErrorView) result.success(null) } + METHOD_RELEASE -> { + // Dart's widget is gone. Here that is the same news [dispose] brings a moment later, + // and the block is released idempotently, so whichever arrives first is the one that + // stops it — the method exists for iOS, where there is no dispose hook at all. + blockView.release() + result.success(null) + } else -> result.notImplemented() } } @@ -224,6 +231,7 @@ internal class EmbeddedBlockPlatformView( 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" diff --git a/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift b/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift index bb71718..220c3bb 100644 --- a/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift +++ b/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift @@ -120,6 +120,12 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { syncStandIns(hasPlaceholder: hasPlaceholder, hasErrorView: hasErrorView) result(nil) + case Keys.release: + // Dart's widget is gone, and it says so while `deinit` is still waiting for the engine + // to let go of the platform view. `release()` is idempotent, so the `deinit` that + // follows finds the block already stopped. + blockView.release() + result(nil) default: result(FlutterMethodNotImplemented) } @@ -192,6 +198,7 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { 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" diff --git a/mindbox_platform_interface/lib/src/embedded_block.dart b/mindbox_platform_interface/lib/src/embedded_block.dart index f9fbd01..507f0da 100644 --- a/mindbox_platform_interface/lib/src/embedded_block.dart +++ b/mindbox_platform_interface/lib/src/embedded_block.dart @@ -71,6 +71,16 @@ class EmbeddedBlockMethods { /// 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. + /// + /// 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. + /// + /// Both native sides release idempotently, so the one that also releases on its own is told + /// something it has already done, and nothing happens twice. + static const String release = 'release'; } /// How the block occupies its place right now — what the wrapper draws, not what happened. From cd6ecdae9d5fb7d921257779c9a105eaf40622bb Mon Sep 17 00:00:00 2001 From: Vailence Date: Wed, 26 Aug 2026 18:14:18 +0500 Subject: [PATCH 09/14] MOBILE-341: Match the outcome-delivery contract in the docs to the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_deliver` dedupes against the last outcome delivered, not against every outcome ever, so `load → fail → load` is three callbacks — and both native containers behave the same way, which makes that the cross-platform contract. "Exactly once per lifetime" was stricter than any of the three implementations. --- mindbox/lib/src/embedded_block.dart | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index dcd8e5e..0547720 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -106,10 +106,16 @@ class MindboxEmbeddedBlock extends StatelessWidget { 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 @@ -404,7 +410,12 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { } /// The native side reports where the block stands, not what changed, so the same outcome can - /// arrive more than once — the host must hear it exactly once. + /// arrive more than once — the host must hear each one once. + /// + /// Deduplicated against the last outcome delivered and not against every outcome ever: a place + /// that fails and then fills up has genuinely changed its answer, and a host that dropped its + /// section on the failure has to hear that it can put it back. The native containers dedupe the + /// same way, so `load → fail → load` is three callbacks on every platform. void _deliver(EmbeddedBlockOutcome? outcome) { if (outcome == null || outcome == _deliveredOutcome) { return; From 0756c09e108b429538a5d680d4822f4176c27faf Mon Sep 17 00:00:00 2001 From: Vailence Date: Wed, 26 Aug 2026 19:47:04 +0500 Subject: [PATCH 10/14] MOBILE-341: Keep comments only where they document the public API --- mindbox/lib/src/embedded_block.dart | 100 ------------------ mindbox/test/embedded_block_test.dart | 30 ------ .../EmbeddedBlockPlatformView.kt | 57 ---------- .../mindbox_android/MindboxAndroidPlugin.kt | 2 - .../Sources/mindbox_ios/Constants.swift | 2 - .../EmbeddedBlockPlatformView.swift | 40 ------- .../mindbox_ios/MindboxIosPlugin.swift | 2 - .../test/src/embedded_block_test.dart | 2 - 8 files changed, 235 deletions(-) diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index 0547720..4722674 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -121,10 +121,6 @@ class MindboxEmbeddedBlock extends StatelessWidget { @override Widget build(BuildContext context) { return _EmbeddedBlock( - // A different place is a different block, and everything remembered about the old one has to - // go with it — the outcome already delivered, the appearance last shown, the height fixed at - // creation. Keying the state and not just the platform view is what `.id(placeSystemName)` - // does in SwiftUI; keying only the view would keep a live State pointing at a dead block. key: ValueKey(placeSystemName), placeSystemName: placeSystemName, height: height, @@ -162,24 +158,12 @@ class _EmbeddedBlock extends StatefulWidget { } class _EmbeddedBlockState extends State<_EmbeddedBlock> { - /// The height as given, kept to tell an ignored new value from the one the block was built with. late final double _creationHeight = widget.height; - /// The height as laid out. Clamped like the native container and the SwiftUI wrapper do — a - /// negative height computed from a `MediaQuery` reaches the block as a broken constraint here, - /// while on the native side it is only a log line and an invisible block. late final double _height = math.max(0, _creationHeight); - /// The budget as given, kept for the same reason the height is: it goes to the native block once, - /// when the platform view is created, and a later value has no container left to reach. - /// - /// Taken in `initState` rather than on first read: on a platform without a native block nothing - /// reads it while the block is being built, and a lazy field would be filled in by the very - /// comparison meant to catch a changed budget — with the changed value. late final Duration? _creationTimeout; - /// Starts where the native container starts: the space is taken and the loading screen is up. The - /// block occupies its height right away, not from the container's first report. EmbeddedBlockAppearance _appearance = EmbeddedBlockAppearance.placeholder; EmbeddedBlockOutcome? _deliveredOutcome; @@ -190,12 +174,6 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { MethodChannel? _channel; - /// What the native side was last *told*, not what the widget last held. - /// - /// The difference is the whole point: a change that happens before the platform view exists has - /// nowhere to go, and comparing against the previous widget would call that change delivered and - /// never mention it again. Compared against this, an undelivered change stays pending until the - /// channel appears. bool? _syncedHasPlaceholder; bool? _syncedHasErrorView; bool? _syncedHostVisible; @@ -206,14 +184,6 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { bool get _hasErrorView => widget.errorBuilder != null; - /// The platforms that have a native block behind the widget. Both wrap the very same container, - /// speak the same channel and answer with the same appearances — that is the whole point of the - /// arrangement, and it is why the widget itself needs no per-platform branch beyond which platform - /// view class to build. - /// - /// The web is excluded by name: there [defaultTargetPlatform] mirrors the browser's host OS, so - /// without [kIsWeb] a phone's browser would claim a native block no browser can build — instead - /// of collapsing the way every other platform without one does. static bool get _isSupported => !kIsWeb && (defaultTargetPlatform == TargetPlatform.iOS || @@ -224,13 +194,6 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { super.initState(); _creationTimeout = widget.timeout; if (!_isSupported) { - // No platform view means no reports and no outcome — and a host told to drop its section in - // `onFail` would keep an empty hole forever waiting for one. Answer the way an empty place - // answers, so the layout around the block behaves the same on every platform. - // `WidgetsBinding.instance` reads as non-nullable only from Flutter 3, and this package still - // declares a 2.0 floor. `ensureInitialized` returns the binding itself on both — inside a - // widget it is long up, so nothing is initialized here: this is the same instance, spelled in - // a way that compiles either side of the change. WidgetsFlutterBinding.ensureInitialized().addPostFrameCallback((_) { if (!mounted) { return; @@ -244,10 +207,6 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { @override void didChangeDependencies() { super.didChangeDependencies(); - // `Overlay` turns tickers off for a route covered by an opaque one, which is exactly when a - // Flutter screen stops being seen while its platform view stays in the window. - // `valuesOf` is the non-deprecated spelling, but it is newer than the Flutter floor this - // package declares, and `of` says everything the block needs. // ignore: deprecated_member_use _isHostVisible = TickerMode.of(context); _pushHostVisible(); @@ -264,9 +223,6 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { void dispose() { final MethodChannel? channel = _channel; if (channel != null) { - // The block's screen is gone, and Dart is the only side that knows it on both platforms: - // Android's platform view has a dispose hook, iOS has none and would wait for the engine to - // let go of the view. Sent before the handler goes, so the native side is still answered. _invoke(channel, EmbeddedBlockMethods.release, null); channel.setMethodCallHandler(null); } @@ -283,16 +239,12 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { fit: StackFit.expand, children: [ _nativeBlock(), - // Nothing to draw is no child at all. An empty widget would be harmless to touches — it - // hit-tests to nothing and the block underneath still hears them — but it is a layer the - // engine has to composite over the platform view for no reason at all. if (hostLayer != null) hostLayer, ], ), ); } - /// The host's own screen for the current appearance, or `null` when the host draws nothing. Widget? _hostLayer(BuildContext context) { switch (_appearance) { case EmbeddedBlockAppearance.placeholder: @@ -313,26 +265,15 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { final Map creationParams = { EmbeddedBlockParams.placeSystemName: widget.placeSystemName, EmbeddedBlockParams.height: _height, - // The container is told that the place is taken, not what goes into it: it holds back its - // shimmer and keeps a failed block standing, and Dart draws the screen itself. EmbeddedBlockParams.hasPlaceholder: _hasPlaceholder, EmbeddedBlockParams.hasErrorView: _hasErrorView, }; - // Sent only when the host named one: an absent key is what tells either native side to keep its - // own default, and there is no number that means "no budget" to put there instead. final Duration? timeout = _creationTimeout; if (timeout != null) { creationParams[EmbeddedBlockParams.timeoutMs] = timeout.inMilliseconds; } - // A block is typically a horizontal carousel inside a vertical scroll. Flutter has no parent to - // ask not to intercept touches — the gesture arena decides — so the platform view has to claim - // horizontal drags itself, or the surrounding list takes them first. - // - // Only while the content is what is on screen. Under a host's own screen the block has nothing - // to scroll, and claiming drags there would take them from a placeholder or a failure screen - // that scrolls or swipes on its own. final Set> gestureRecognizers = _appearance == EmbeddedBlockAppearance.content ? >{ @@ -342,9 +283,6 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { } : const >{}; - // The only place in the widget that knows which platform it is on. Everything else — the layers, - // the height, the outcome, the two signals sent down — is written once and reads the same answer - // from either native side. if (defaultTargetPlatform == TargetPlatform.android) { return AndroidView( viewType: embeddedBlockViewType, @@ -365,11 +303,6 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { } void _listenTo(int viewId) { - // A platform view can be built again for the same `State` — a new id, a new native container - // that has heard nothing. What was sent to the previous one is not what this one knows, and - // left in place it would silence the resend below: a block whose host is already hidden would - // match the answer it sent the old view, say nothing, and let the new one — which starts out - // believing it is on screen — spend its whole waiting budget behind a covered route. _channel?.setMethodCallHandler(null); _syncedHasPlaceholder = null; _syncedHasErrorView = null; @@ -378,15 +311,7 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { final MethodChannel channel = MethodChannel(embeddedBlockChannelName(viewId)); channel.setMethodCallHandler(_handle); _channel = channel; - // Where does the block stand? Asked rather than assumed: the container hands out its appearance - // the moment the native wrapper subscribes, which is while the platform view is being built — - // before this handler existed. A place with nothing behind it settles right there, and its only - // report would be lost, leaving the widget on a loading screen for a block that already gave its - // space back. _invoke(channel, EmbeddedBlockMethods.sync, null); - // Everything the block was told before it existed is told now. The platform view is created a - // few frames after the first build, and a host that gains a failure screen — or leaves the - // screen — inside that window would otherwise be heard by nobody, permanently. _pushStandIns(); _pushHostVisible(); } @@ -409,13 +334,6 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { _deliver(report.outcome); } - /// The native side reports where the block stands, not what changed, so the same outcome can - /// arrive more than once — the host must hear each one once. - /// - /// Deduplicated against the last outcome delivered and not against every outcome ever: a place - /// that fails and then fills up has genuinely changed its answer, and a host that dropped its - /// section on the failure has to hear that it can put it back. The native containers dedupe the - /// same way, so `load → fail → load` is three callbacks on every platform. void _deliver(EmbeddedBlockOutcome? outcome) { if (outcome == null || outcome == _deliveredOutcome) { return; @@ -429,12 +347,6 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { } } - /// Whether the host draws its own screens can change between builds — a placeholder given only - /// while a feature flag is on, a failure screen added once the section knows it can retry. - /// - /// Only the answer travels, not the builder: a widget rebuilt with a different closure that still - /// draws a placeholder is the same answer, and telling the container about it on every frame would - /// make it swap its layers for nothing. void _pushStandIns() { final MethodChannel? channel = _channel; if (channel == null || @@ -454,12 +366,6 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { ); } - /// Tells the block whether the screen it stands on is still the one being looked at. - /// - /// The native container watches its window, and in Flutter that is not enough: every screen shares - /// one window, so pushing a route over the block never takes it out. Left alone, the block would - /// spend its whole waiting budget behind another screen and collapse before the user came back to - /// a place that never gets its space again. void _pushHostVisible() { final MethodChannel? channel = _channel; if (channel == null || _syncedHostVisible == _isHostVisible) { @@ -470,9 +376,6 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { _invoke(channel, EmbeddedBlockMethods.setHostVisible, _isHostVisible); } - /// Sends and forgets, but does not leave the failure unhandled: a call into a platform view the - /// engine has already disposed answers with a `MissingPluginException`, and an uncaught one - /// surfaces to the host as a crash report for a block that is simply gone. void _invoke(MethodChannel channel, String method, Object? arguments) { channel.invokeMethod(method, arguments).catchError((Object error) { debugPrint('[MindboxEmbeddedBlock] $method for block "${widget.placeSystemName}" ' @@ -480,9 +383,6 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { }); } - /// Both the height and the budget are settled when the block is built and cannot be talked out of - /// it afterwards. Said once per value, and separately: a host that changed only one of them should - /// hear about that one. void _warnIfCreationValuesAreIgnored() { if (!_hasWarnedAboutHeight && widget.height != _creationHeight) { _hasWarnedAboutHeight = true; diff --git a/mindbox/test/embedded_block_test.dart b/mindbox/test/embedded_block_test.dart index 5cb2d69..e6d6a6f 100644 --- a/mindbox/test/embedded_block_test.dart +++ b/mindbox/test/embedded_block_test.dart @@ -5,11 +5,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mindbox/mindbox.dart'; import 'package:mindbox_platform_interface/mindbox_platform_interface.dart'; -/// Runs [body] on a platform the block has no native half for. -/// -/// The override is cleared inside the test rather than in a `tearDown`: `flutter_test` checks the -/// foundation debug variables on the way out of the body, before any teardown runs. - void testWithoutNativeBlock(String description, Future Function(WidgetTester) body) { testWidgets(description, (WidgetTester tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.linux; @@ -22,9 +17,6 @@ void testWithoutNativeBlock(String description, Future Function(WidgetTest } void main() { - // The widget draws a platform view on iOS and Android, and neither exists in a widget test. What - // is checked here is the part written once and read the same on every platform: the layout the - // block hands back, the screens the host draws, and the outcome it hears. group('On a platform without a native block', () { testWithoutNativeBlock('The block collapses and reports a failure', (WidgetTester tester) async { @@ -44,12 +36,10 @@ void main() { ), )); - // The space is taken before anything is known about the place. expect(tester.getSize(find.byType(MindboxEmbeddedBlock)).height, 104); await tester.pump(); - // And handed back once it turns out there is no block behind it. expect(tester.getSize(find.byType(MindboxEmbeddedBlock)).height, 0); expect(fails, 1); expect(loads, 0); @@ -135,7 +125,6 @@ void main() { await tester.pump(); expect(fails, 1); - // Everything remembered about the old block goes with it: the new one reports its own outcome. await buildFor('promo'); await tester.pump(); expect(fails, 2); @@ -169,16 +158,12 @@ void main() { debugPrint = printed; } - // Once, however many times the host tries: a widget rebuilt every frame would otherwise fill - // the log with the same line. expect(log.where((String line) => line.contains('timeout')), hasLength(1)); expect(log.single, contains('"stories"')); expect(log.single, contains('0:00:05')); }); }); - // What the host asks for has to reach the native container, and that is the one thing a widget - // test can still see of it: the creation params the platform view is built with. group('The waiting budget', () { late List> created; @@ -192,13 +177,9 @@ void main() { final Map arguments = call.arguments as Map; final Uint8List params = arguments['params'] as Uint8List; - // The view's own window into the buffer, not the whole buffer: the engine hands over a - // slice, and decoding from byte zero of what it is a slice of reads somebody else's message. created.add(const StandardMessageCodec().decodeMessage( params.buffer.asByteData(params.offsetInBytes, params.lengthInBytes), ) as Map); - // A texture id, which is what the Android controller reads back and casts. iOS ignores the - // answer, so one number serves both. return 0; }); }); @@ -208,8 +189,6 @@ void main() { .setMockMethodCallHandler(SystemChannels.platform_views, null); }); - /// Builds a block on [platform] — the only two the widget has a native half for — and hands back - /// the params its platform view was created with. Future> paramsOf( WidgetTester tester, TargetPlatform platform, { @@ -259,8 +238,6 @@ void main() { (WidgetTester tester) async { final Map params = await paramsOf(tester, platform); - // Absent, not zero: there is no number that means "no budget", and a native side that finds - // nothing keeps its own 30 seconds. expect(params.containsKey('timeoutMs'), isFalse); }); } @@ -273,16 +250,10 @@ void main() { timeout: Duration.zero, ); - // Not a budget any block could survive — and not Dart's call: both native containers already - // fall back to their default and log what they were given, and second-guessing that here - // would put the same rule in three places, spelled three ways. expect(params['timeoutMs'], 0); }); }); - // Dart is the only side that knows the widget is gone on both platforms: Android's platform view - // has a dispose hook and iOS has none, where the block would otherwise be stopped by whenever the - // engine happens to let go of the view. group('Leaving the screen', () { late List methods; @@ -303,7 +274,6 @@ void main() { return null; }, ); - // A texture id, which is what the Android controller reads back and casts. return 0; }); }); 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 index c5f4d25..148c1b7 100644 --- 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 @@ -16,14 +16,6 @@ import io.flutter.plugin.common.StandardMessageCodec import io.flutter.plugin.platform.PlatformView import io.flutter.plugin.platform.PlatformViewFactory -/** - * Builds the native embedded block for a Flutter platform view. - * - * The block itself is the SDK's `MindboxEmbeddedBlockView`, whole and unchanged: the content - * factory, the waiting budget, the page and its bridge stay on the native side, and Flutter gets a - * view to place plus the signals to react to. A Dart implementation over a WebView plugin would have - * to reproduce all of that and then keep up with it release after release. - */ @OptIn(InternalMindboxApi::class) internal class EmbeddedBlockPlatformViewFactory( private val messenger: BinaryMessenger, @@ -33,7 +25,6 @@ internal class EmbeddedBlockPlatformViewFactory( EmbeddedBlockPlatformView(context, viewId, args, messenger) } -/** One block on a Flutter screen: the native container plus the channel it reports through. */ @OptIn(InternalMindboxApi::class) internal class EmbeddedBlockPlatformView( private val context: Context, @@ -45,19 +36,9 @@ internal class EmbeddedBlockPlatformView( private val blockView: MindboxEmbeddedBlockView private val channel: MethodChannel - /** - * The last pair sent up. Kept because the two signals arrive separately while Dart needs them - * together: the appearance observer fires inside the container's state change, the outcome on the - * next turn of the main looper — so each message carries the whole picture, not a delta. - */ private var appearance = PLACEHOLDER private var outcome: String? = null - /** - * The stand-ins currently handed to the container, kept to tell "the host still draws its own - * screen" from "it has just started to". Declared above `init`, which sets them: a property - * initializer further down the class would run afterwards and put the null back. - */ private var placeholderStandIn: View? = null private var errorStandIn: View? = null @@ -65,20 +46,11 @@ internal class EmbeddedBlockPlatformView( val params = arguments as? Map<*, *> val placeSystemName = params?.get(KEY_PLACE_SYSTEM_NAME) as? String ?: "" - // Absent means the host named no budget, and that is exactly the container's own `null`: the - // SDK default. Read as a Number rather than an Int: the standard codec sends a value that - // fits in 32 bits as an Int and a longer one as a Long, and a budget in milliseconds sits - // right where the two meet. val timeoutMs = (params?.get(KEY_TIMEOUT_MS) as? Number)?.toLong() - // The height is not passed on: on Android the block is a frame sized by its parent, and here - // that parent is Flutter — the platform view is laid out to the height Dart gives it. blockView = MindboxEmbeddedBlockView(context, placeSystemName, timeoutMs) channel = MethodChannel(messenger, "$VIEW_TYPE/$viewId") - // Said here rather than left to the container: it does warn about a place it cannot resolve, - // but in the words of the XML attribute it was written for, which names nothing a Flutter - // host can set. The same mistake is reported the same way on both platforms. if (placeSystemName.isEmpty()) { Mindbox.writeLog( message = "[EmbeddedBlock] A Flutter block was created without a place system name " + @@ -100,16 +72,12 @@ internal class EmbeddedBlockPlatformView( override fun onFail(view: MindboxEmbeddedBlockView) = report(outcome = FAIL) }, ) - // Last, and after the handler is in place: subscribing hands out the current appearance right - // away, and an empty place answers synchronously while the block attaches. blockView.setAppearanceObserver { appearance -> report(appearance) } } override fun getView(): View = blockView override fun dispose() { - // The platform view is gone, so the block's screen is gone with it. Waiting for the host - // Activity to be destroyed instead would keep a page loading for a screen nobody can see. blockView.setAppearanceObserver(null) blockView.setListener(null) blockView.release() @@ -119,8 +87,6 @@ internal class EmbeddedBlockPlatformView( override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { METHOD_SYNC -> { - // Dart has its handler up now and asks where the block stands. Everything reported - // before this point went to a channel nobody was listening on yet. send() result.success(null) } @@ -149,9 +115,6 @@ internal class EmbeddedBlockPlatformView( result.success(null) } METHOD_RELEASE -> { - // Dart's widget is gone. Here that is the same news [dispose] brings a moment later, - // and the block is released idempotently, so whichever arrives first is the one that - // stops it — the method exists for iOS, where there is no dispose hook at all. blockView.release() result.success(null) } @@ -159,18 +122,7 @@ internal class EmbeddedBlockPlatformView( } } - /** - * Puts an empty view where the host draws its own screen — the same arrangement the Compose - * wrapper uses for a slot it cannot hand over directly. - * - * A Flutter widget cannot become an Android View, so the container is not given the screen: it is - * given the fact that the place is taken. That is all it needs — a placeholder of its own is held - * back, and a failed block keeps its height instead of collapsing. What is actually drawn in that - * space is a widget, laid out by Flutter over the platform view. - */ private fun syncStandIns(hasPlaceholder: Boolean, hasErrorView: Boolean) { - // Assigned only on a change: the container swaps its shown layer on every new view, and a - // fresh stand-in on every Dart rebuild would swap it for an identical one. if (hasPlaceholder) { if (placeholderStandIn == null) { placeholderStandIn = makeStandIn() @@ -194,8 +146,6 @@ internal class EmbeddedBlockPlatformView( private fun makeStandIn(): View = View(context).apply { setBackgroundColor(Color.TRANSPARENT) - // The stand-in is a placeholder for space, not for touches: what the host drew over it is a - // widget, and it is Flutter that has to hear the taps on it. isClickable = false isFocusable = false } @@ -211,8 +161,6 @@ internal class EmbeddedBlockPlatformView( } private fun send() { - // No outcome key while there is no outcome: a null inside the map would have to survive the - // standard codec, and "the key is absent" says the same thing without relying on that. val arguments = mutableMapOf(KEY_APPEARANCE to appearance) outcome?.let { arguments[KEY_OUTCOME] = it } channel.invokeMethod(METHOD_REPORT, arguments) @@ -240,10 +188,6 @@ internal class EmbeddedBlockPlatformView( const val ERROR = "error" const val COLLAPSED = "collapsed" - /** - * Spelled out rather than taken from the enum name: the wire word is a contract with the Dart - * side, and renaming a case in the SDK must not quietly change it. - */ fun nameOf(appearance: MindboxEmbeddedBlockAppearance): String = when (appearance) { MindboxEmbeddedBlockAppearance.PLACEHOLDER -> PLACEHOLDER MindboxEmbeddedBlockAppearance.CONTENT -> CONTENT @@ -253,5 +197,4 @@ internal class EmbeddedBlockPlatformView( } } -/** The type both native factories register the block under — must match the Dart constant. */ 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 0fccd0f..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,8 +56,6 @@ class MindboxAndroidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, Ne override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { channel = MethodChannel(flutterPluginBinding.binaryMessenger, "mindbox.cloud/flutter-sdk") channel.setMethodCallHandler(this) - // Registered on the engine and not on the Activity: a block is a view a Dart widget asks for, - // and the widget may be built before this plugin ever sees an Activity. flutterPluginBinding.platformViewRegistry.registerViewFactory( EMBEDDED_BLOCK_VIEW_TYPE, EmbeddedBlockPlatformViewFactory(flutterPluginBinding.binaryMessenger), 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 39fb297..89bbb57 100644 --- a/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/Constants.swift +++ b/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/Constants.swift @@ -10,7 +10,5 @@ import Foundation enum Constants { static let pluginChannelName = "mindbox.cloud/flutter-sdk"; - /// Matches `embeddedBlockViewType` in `mindbox_platform_interface`: the Dart widget asks for the - /// platform view by this name, so the two spellings cannot drift apart. 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 index 220c3bb..694c68f 100644 --- a/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift +++ b/mindbox_ios/ios/mindbox_ios/Sources/mindbox_ios/EmbeddedBlockPlatformView.swift @@ -3,12 +3,6 @@ import UIKit @_spi(Internal) import Mindbox import MindboxLogger -/// Builds the native embedded block for a Flutter platform view. -/// -/// The block itself is the SDK's `MindboxEmbeddedBlockView`, whole and unchanged: the resolver, the -/// waiting budget, the page and its bridge stay on the native side, and Flutter gets a view to place -/// plus the signals to react to. A Dart implementation over a WebView plugin would have to reproduce -/// all of that and then keep up with it release after release. public final class EmbeddedBlockPlatformViewFactory: NSObject, FlutterPlatformViewFactory { private let messenger: FlutterBinaryMessenger @@ -29,15 +23,11 @@ public final class EmbeddedBlockPlatformViewFactory: NSObject, FlutterPlatformVi } } -/// One block on a Flutter screen: the native container plus the channel it reports through. final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { private let blockView: MindboxEmbeddedBlockView private let channel: FlutterMethodChannel - /// The last pair sent up. Kept because the two signals arrive separately while Dart needs them - /// together: the appearance observer fires inside the container's state change, the outcome on - /// the next turn of the main queue — so each message carries the whole picture, not a delta. private var appearance = Keys.placeholder private var outcome: String? @@ -45,9 +35,6 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { let params = arguments as? [String: Any] let placeSystemName = params?[Keys.placeSystemName] as? String ?? "" let height = (params?[Keys.height] as? NSNumber)?.doubleValue ?? 0 - // Absent means the host named no budget, and that is exactly the container's own `nil`: the - // SDK default. Milliseconds on the wire, seconds here — the container counts in seconds, and - // an integer is what the standard codec carries the same way from either Dart side. let timeout = (params?[Keys.timeoutMs] as? NSNumber).map { TimeInterval($0.doubleValue) / 1000 } blockView = MindboxEmbeddedBlockView(placeSystemName: placeSystemName, @@ -76,13 +63,6 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { } deinit { - // The platform view is gone, so the block's screen is gone with it. Waiting for the last - // reference to go instead would keep a page loading for a screen nobody can see. - // - // `release()` and nothing else: it marks the block released before dropping the delegate and - // the observer itself. Dropping the delegate here first would do it while the block still - // counts as live, and its `didSet` would read the change as a new subscriber and schedule a - // delivery on the main queue for a view being torn down. blockView.release() channel.setMethodCallHandler(nil) } @@ -94,8 +74,6 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case Keys.sync: - // Dart has its handler up now and asks where the block stands. Everything reported before - // this point went to a channel nobody was listening on yet. send() result(nil) case Keys.setHostVisible: @@ -121,9 +99,6 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { syncStandIns(hasPlaceholder: hasPlaceholder, hasErrorView: hasErrorView) result(nil) case Keys.release: - // Dart's widget is gone, and it says so while `deinit` is still waiting for the engine - // to let go of the platform view. `release()` is idempotent, so the `deinit` that - // follows finds the block already stopped. blockView.release() result(nil) default: @@ -131,16 +106,7 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { } } - /// Puts an empty view where the host draws its own screen — the same arrangement the SwiftUI - /// wrapper uses. - /// - /// A Flutter widget cannot become a `UIView`, so the container is not given the screen: it is - /// given the fact that the place is taken. That is all it needs — a placeholder of its own is - /// held back, and a failed block keeps its height instead of collapsing. What is actually drawn - /// in that space is a widget, laid out by Flutter over the platform view. private func syncStandIns(hasPlaceholder: Bool, hasErrorView: Bool) { - // Assigned only on a change: the container swaps its shown layer on every new view, and - // a fresh stand-in on every Dart rebuild would swap it for an identical one. if hasPlaceholder { if blockView.placeholderView == nil { blockView.placeholderView = Self.makeStandIn() @@ -161,8 +127,6 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { private static func makeStandIn() -> UIView { let standIn = UIView() standIn.backgroundColor = .clear - // The stand-in is a placeholder for space, not for touches: what the host drew over it is a - // widget, and it is Flutter that has to hear the taps on it. standIn.isUserInteractionEnabled = false return standIn } @@ -178,8 +142,6 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { } private func send() { - // No outcome key while there is no outcome: a nil inside the dictionary would have to survive - // the standard codec, and "the key is absent" says the same thing without relying on that. var arguments: [String: Any] = [Keys.appearance: appearance] if let outcome = outcome { arguments[Keys.outcome] = outcome @@ -208,8 +170,6 @@ final class EmbeddedBlockPlatformView: NSObject, FlutterPlatformView { static let error = "error" static let collapsed = "collapsed" - /// Spelled out rather than derived from the case name: the wire word is a contract with the - /// Dart side, and renaming a case in the SDK must not quietly change it. static func name(of appearance: MindboxEmbeddedBlockAppearance) -> String { switch appearance { case .placeholder: return placeholder 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 4ba7740..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,8 +15,6 @@ public class MindboxIosPlugin: NSObject, FlutterPlugin { registrar.addMethodCallDelegate(instance, channel: channel) registrar.addApplicationDelegate(instance) - // The embedded block is a view, not a call: it gets a platform view factory instead of a - // method on the plugin channel, and talks over a channel of its own per created block. registrar.register(EmbeddedBlockPlatformViewFactory(messenger: registrar.messenger()), withId: Constants.embeddedBlockViewType) } diff --git a/mindbox_platform_interface/test/src/embedded_block_test.dart b/mindbox_platform_interface/test/src/embedded_block_test.dart index 895e1af..ff1f4f1 100644 --- a/mindbox_platform_interface/test/src/embedded_block_test.dart +++ b/mindbox_platform_interface/test/src/embedded_block_test.dart @@ -39,7 +39,6 @@ void main() { EmbeddedBlockReport.tryParse({'appearance': word}); expect(report?.appearance, expected, reason: word); }); - // Every case is covered, so a new appearance cannot be added without this failing. expect(wire.length, EmbeddedBlockAppearance.values.length); }); @@ -65,7 +64,6 @@ void main() { {'appearance': 'sideways', 'outcome': 'maybe', 'extra': 1}, ); - // Parsed, not rejected: an unknown word leaves the host on what it already knew. expect(report, isNotNull); expect(report!.appearance, isNull); expect(report.outcome, isNull); From 3d2062cfaaab54f7c7247b290d59b943ebd3078f Mon Sep 17 00:00:00 2001 From: Vailence Date: Thu, 27 Aug 2026 13:51:39 +0500 Subject: [PATCH 11/14] MOBILE-341: Leave the Android block to its own dispose hook The platform view is a child of the widget that owns the channel, so it is unmounted first: by the time the widget could send release, the Android dispose hook has already released the block and taken the channel handler with it, and the message came back as a missing plugin on every dispose. --- mindbox/lib/src/embedded_block.dart | 4 +- mindbox/test/embedded_block_test.dart | 63 ++++++++++--------- .../lib/src/embedded_block.dart | 14 +++-- 3 files changed, 45 insertions(+), 36 deletions(-) diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index 4722674..2166f9d 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -223,7 +223,9 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { void dispose() { final MethodChannel? channel = _channel; if (channel != null) { - _invoke(channel, EmbeddedBlockMethods.release, null); + if (defaultTargetPlatform == TargetPlatform.iOS) { + _invoke(channel, EmbeddedBlockMethods.release, null); + } channel.setMethodCallHandler(null); } super.dispose(); diff --git a/mindbox/test/embedded_block_test.dart b/mindbox/test/embedded_block_test.dart index e6d6a6f..d28b8a6 100644 --- a/mindbox/test/embedded_block_test.dart +++ b/mindbox/test/embedded_block_test.dart @@ -283,39 +283,42 @@ void main() { .setMockMethodCallHandler(SystemChannels.platform_views, null); }); - for (final TargetPlatform platform in [ - TargetPlatform.iOS, - TargetPlatform.android, - ]) { - final String name = platform == TargetPlatform.iOS ? 'iOS' : 'Android'; - - testWidgets('A disposed widget tells the $name block to stop', - (WidgetTester tester) async { - debugDefaultTargetPlatformOverride = platform; - try { - await tester.pumpWidget(const Directionality( - textDirection: TextDirection.ltr, - child: Align( - alignment: Alignment.topLeft, - child: MindboxEmbeddedBlock( - placeSystemName: 'stories', - height: 104, - ), + 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.pumpAndSettle(); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pumpAndSettle(); + expect(methods, contains(EmbeddedBlockMethods.sync)); + expect(methods, isNot(contains(EmbeddedBlockMethods.release))); - expect(methods.last, EmbeddedBlockMethods.release); - } finally { - debugDefaultTargetPlatformOverride = null; - } - }); + 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_platform_interface/lib/src/embedded_block.dart b/mindbox_platform_interface/lib/src/embedded_block.dart index 507f0da..c058516 100644 --- a/mindbox_platform_interface/lib/src/embedded_block.dart +++ b/mindbox_platform_interface/lib/src/embedded_block.dart @@ -74,12 +74,16 @@ class EmbeddedBlockMethods { /// Dart → native: the widget is gone — stop the block now, not when the last reference to it is. /// - /// 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. + /// 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. /// - /// Both native sides release idempotently, so the one that also releases on its own is told - /// something it has already done, and nothing happens twice. + /// 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'; } From 6632112e434b2b6fe311ed520543f237233eb5b9 Mon Sep 17 00:00:00 2001 From: Vailence Date: Thu, 27 Aug 2026 21:27:30 +0500 Subject: [PATCH 12/14] MOBILE-341: Say out loud that a block with no height loads nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flutter never asks the platform to create a view for an empty box, so a block given a height of 0 gets no view, no channel and no outcome on Android, and not a line anywhere; iOS creates its view whatever the size and reports as usual. A host that takes the height from a remote config and gets 0 back sees silence on one platform and diagnostics on the other, so the widget now writes it down at creation — for zero, for a negative height and for NaN alike. --- mindbox/lib/src/embedded_block.dart | 12 +++++++ mindbox/test/embedded_block_test.dart | 48 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index 2166f9d..3e83c3f 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -193,6 +193,7 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { void initState() { super.initState(); _creationTimeout = widget.timeout; + _warnIfHeightReservesNoSpace(); if (!_isSupported) { WidgetsFlutterBinding.ensureInitialized().addPostFrameCallback((_) { if (!mounted) { @@ -385,6 +386,17 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { }); } + void _warnIfHeightReservesNoSpace() { + if (widget.height > 0) { + return; + } + + debugPrint( + '[MindboxEmbeddedBlock] Block "${widget.placeSystemName}" was created with height ' + '${widget.height}: it reserves no space and nothing loads.', + ); + } + void _warnIfCreationValuesAreIgnored() { if (!_hasWarnedAboutHeight && widget.height != _creationHeight) { _hasWarnedAboutHeight = true; diff --git a/mindbox/test/embedded_block_test.dart b/mindbox/test/embedded_block_test.dart index d28b8a6..07d061d 100644 --- a/mindbox/test/embedded_block_test.dart +++ b/mindbox/test/embedded_block_test.dart @@ -254,6 +254,54 @@ void main() { }); }); + 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('Leaving the screen', () { late List methods; From cd15f4228088959f0f9b41f52b971e9ca0b05688 Mon Sep 17 00:00:00 2001 From: Vailence Date: Fri, 28 Aug 2026 01:43:10 +0500 Subject: [PATCH 13/14] MOBILE-341: Let a new height resize the block instead of freezing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing native was ever laid out from the frozen value: the Android half of the plugin does not read the height key at all, and on iOS it only reached preferredHeight — intrinsicContentSize, which UiKitView never asks for. The height in the Flutter layout comes from the SizedBox, so the freeze protected nothing and the way out it recommended — a new Key — threw the platform view away: the block ran the whole selection again, showed a placeholder and could collapse in a place that was fine a second ago. Compose takes the height live from the host's modifier, and iOS now does the same in its SwiftUI wrapper, so this is the contract all three wrappers share. The warning about an ignored height is gone with the frozen value; the one about the timeout stays, since that is consumed at creation. The diagnostic for a height that reserves no space now covers a non-finite one too — the layout clamps it to zero exactly like a negative height. Creation params keep the height: iOS logs a zero one from there. --- mindbox/README.md | 6 +-- mindbox/lib/src/embedded_block.dart | 33 ++++--------- mindbox/test/embedded_block_test.dart | 70 +++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 27 deletions(-) diff --git a/mindbox/README.md b/mindbox/README.md index 1a4294a..b20b041 100644 --- a/mindbox/README.md +++ b/mindbox/README.md @@ -74,9 +74,9 @@ MindboxEmbeddedBlock( ) ``` -Both `height` and `timeout` are fixed when the block is created — a new value given to a block -already on screen is ignored and reported to the log. Give the widget a new `Key` to build a block -on new terms. +`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. diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index 3e83c3f..971f20d 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -61,11 +61,9 @@ class MindboxEmbeddedBlock extends StatelessWidget { /// scratch in place of the old one. final String placeSystemName; - /// The height the block occupies while it loads and while it is shown. Fixed when the block is - /// created: a new value given to a live block is ignored and reported to the log. - /// - /// To resize a block that is already on screen, give the widget a new [Key] — that is a new block, - /// built from scratch, and it reloads its content. + /// 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 @@ -83,8 +81,8 @@ class MindboxEmbeddedBlock extends StatelessWidget { /// 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, exactly as [height] is: 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. + /// 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. @@ -158,9 +156,7 @@ class _EmbeddedBlock extends StatefulWidget { } class _EmbeddedBlockState extends State<_EmbeddedBlock> { - late final double _creationHeight = widget.height; - - late final double _height = math.max(0, _creationHeight); + double get _height => widget.height.isFinite ? math.max(0, widget.height) : 0; late final Duration? _creationTimeout; @@ -168,8 +164,6 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { EmbeddedBlockOutcome? _deliveredOutcome; - bool _hasWarnedAboutHeight = false; - bool _hasWarnedAboutTimeout = false; MethodChannel? _channel; @@ -216,7 +210,7 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { @override void didUpdateWidget(covariant _EmbeddedBlock oldWidget) { super.didUpdateWidget(oldWidget); - _warnIfCreationValuesAreIgnored(); + _warnIfTimeoutIsIgnored(); _pushStandIns(); } @@ -387,7 +381,7 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { } void _warnIfHeightReservesNoSpace() { - if (widget.height > 0) { + if (widget.height.isFinite && widget.height > 0) { return; } @@ -397,16 +391,7 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { ); } - void _warnIfCreationValuesAreIgnored() { - if (!_hasWarnedAboutHeight && widget.height != _creationHeight) { - _hasWarnedAboutHeight = true; - debugPrint( - '[MindboxEmbeddedBlock] Block "${widget.placeSystemName}" was given height ${widget.height} ' - 'after creation and keeps $_creationHeight: the height is fixed when the block is created. ' - 'Give the widget a new Key to build a block of a different height.', - ); - } - + void _warnIfTimeoutIsIgnored() { if (!_hasWarnedAboutTimeout && widget.timeout != _creationTimeout) { _hasWarnedAboutTimeout = true; debugPrint( diff --git a/mindbox/test/embedded_block_test.dart b/mindbox/test/embedded_block_test.dart index 07d061d..898a639 100644 --- a/mindbox/test/embedded_block_test.dart +++ b/mindbox/test/embedded_block_test.dart @@ -302,6 +302,76 @@ void main() { }); }); + 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; From 2d49c726b36bad37e80372856bfc93ba94abedba Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 31 Aug 2026 15:13:01 +0500 Subject: [PATCH 14/14] MOBILE-341: Say out loud that a place name is padded with spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing trims the name any more, on any platform, which is what makes a name copied from the admin panel with a trailing space behave the same everywhere — and everywhere it silently matches nothing. The block waits out its budget and collapses as an empty place, exactly as it would for a name that is simply wrong, and the log said nothing about the one difference the eye cannot see. The widget now writes it down when the block is created, next to the diagnostic for a height that reserves no space. --- mindbox/lib/src/embedded_block.dart | 16 ++++++++++ mindbox/test/embedded_block_test.dart | 43 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index 971f20d..2943ea5 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -59,6 +59,9 @@ class MindboxEmbeddedBlock extends StatelessWidget { /// 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 @@ -187,6 +190,7 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { void initState() { super.initState(); _creationTimeout = widget.timeout; + _warnIfPlaceIsPadded(); _warnIfHeightReservesNoSpace(); if (!_isSupported) { WidgetsFlutterBinding.ensureInitialized().addPostFrameCallback((_) { @@ -380,6 +384,18 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { }); } + 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; diff --git a/mindbox/test/embedded_block_test.dart b/mindbox/test/embedded_block_test.dart index 898a639..698b353 100644 --- a/mindbox/test/embedded_block_test.dart +++ b/mindbox/test/embedded_block_test.dart @@ -302,6 +302,49 @@ void main() { }); }); + 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;