diff --git a/modules/ensemble/lib/widget/lottie/dot_lottie_bytes_view.dart b/modules/ensemble/lib/widget/lottie/dot_lottie_bytes_view.dart new file mode 100644 index 000000000..07602d7b2 --- /dev/null +++ b/modules/ensemble/lib/widget/lottie/dot_lottie_bytes_view.dart @@ -0,0 +1,164 @@ +import 'package:ensemble/widget/lottie/ensemble_dot_lottie_controller.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// [DotLottieView] with `sourceType: 'asset'` converts `.lottie` bytes to +/// `sourceType: 'data'` before creating the platform view. Android's native +/// player does not accept `data:` URLs via `sourceType: 'url'` (iOS does). +/// +/// This widget passes bundle-loaded bytes the same way the package does for assets. +class DotLottieBytesView extends StatefulWidget { + final Uint8List bytes; + final bool? autoplay; + final bool? loop; + final BoxFit? fit; + final int? width; + final int? height; + final Function(EnsembleDotLottieController)? onViewCreated; + final VoidCallback? onComplete; + final VoidCallback? onLoad; + final VoidCallback? onLoadError; + final VoidCallback? onPlay; + final VoidCallback? onStop; + + const DotLottieBytesView({ + super.key, + required this.bytes, + this.autoplay, + this.loop, + this.fit, + this.width, + this.height, + this.onViewCreated, + this.onComplete, + this.onLoad, + this.onLoadError, + this.onPlay, + this.onStop, + }); + + @override + State createState() => _DotLottieBytesViewState(); +} + +class _DotLottieBytesViewState extends State { + MethodChannel? _methodChannel; + int _platformViewGeneration = 0; + + @override + void didUpdateWidget(DotLottieBytesView oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.bytes, widget.bytes)) { + setState(() => _platformViewGeneration++); + } + } + + @override + Widget build(BuildContext context) { + final params = _creationParams(); + if (defaultTargetPlatform == TargetPlatform.android) { + return AndroidView( + key: ValueKey(_platformViewGeneration), + viewType: 'dotlottie_view', + creationParams: params, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: _onPlatformViewCreated, + gestureRecognizers: const >{}, + ); + } + if (defaultTargetPlatform == TargetPlatform.iOS) { + return UiKitView( + key: ValueKey(_platformViewGeneration), + viewType: 'dotlottie_view', + creationParams: params, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: _onPlatformViewCreated, + gestureRecognizers: const >{}, + ); + } + if (defaultTargetPlatform == TargetPlatform.macOS) { + return AppKitView( + key: ValueKey(_platformViewGeneration), + viewType: 'dotlottie_view', + creationParams: params, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: _onPlatformViewCreated, + gestureRecognizers: const >{}, + ); + } + return const SizedBox.shrink(); + } + + Map _creationParams() { + return { + 'autoplay': widget.autoplay, + 'loop': widget.loop, + 'speed': 1.0, + 'mode': 'forward', + 'useFrameInterpolation': false, + if (widget.fit != null) 'fit': _boxFitToString(widget.fit), + if (widget.width != null) 'width': widget.width, + if (widget.height != null) 'height': widget.height, + 'sourceType': 'data', + 'source': widget.bytes, + }; + } + + static String? _boxFitToString(BoxFit? fit) { + switch (fit) { + case BoxFit.contain: + return 'contain'; + case BoxFit.cover: + return 'cover'; + case BoxFit.fill: + return 'fill'; + case BoxFit.fitWidth: + return 'fitWidth'; + case BoxFit.fitHeight: + return 'fitHeight'; + case BoxFit.none: + return 'none'; + case BoxFit.scaleDown: + return 'contain'; + case null: + return null; + } + } + + void _onPlatformViewCreated(int viewId) { + _methodChannel?.setMethodCallHandler(null); + + _methodChannel = MethodChannel('dotlottie_view_$viewId'); + _methodChannel!.setMethodCallHandler(_handleMethodCall); + widget.onViewCreated?.call(EnsembleDotLottieController.fromViewId(viewId)); + } + + Future _handleMethodCall(MethodCall call) async { + if (!mounted) return; + switch (call.method) { + case 'onComplete': + widget.onComplete?.call(); + break; + case 'onLoad': + widget.onLoad?.call(); + break; + case 'onLoadError': + widget.onLoadError?.call(); + break; + case 'onPlay': + widget.onPlay?.call(); + break; + case 'onStop': + widget.onStop?.call(); + break; + } + } + + @override + void dispose() { + _methodChannel?.setMethodCallHandler(null); + super.dispose(); + } +} diff --git a/modules/ensemble/lib/widget/lottie/ensemble_dot_lottie_controller.dart b/modules/ensemble/lib/widget/lottie/ensemble_dot_lottie_controller.dart new file mode 100644 index 000000000..d1aa6100a --- /dev/null +++ b/modules/ensemble/lib/widget/lottie/ensemble_dot_lottie_controller.dart @@ -0,0 +1,67 @@ +import 'package:dotlottie_flutter/dotlottie_flutter.dart'; +import 'package:flutter/services.dart'; + +/// Playback handle for dotlottie platform views (JSON/URL via [DotLottieView], +/// bundle `.lottie` bytes via [DotLottieBytesView]). +class EnsembleDotLottieController { + EnsembleDotLottieController._(this._delegate, this._viewId); + + factory EnsembleDotLottieController.wrap(DotLottieViewController delegate) { + return EnsembleDotLottieController._(delegate, null); + } + + factory EnsembleDotLottieController.fromViewId(int viewId) { + return EnsembleDotLottieController._(null, viewId); + } + + final DotLottieViewController? _delegate; + final int? _viewId; + + MethodChannel get _channel => MethodChannel('dotlottie_view_$_viewId'); + + Future play() async { + final delegate = _delegate; + if (delegate != null) return delegate.play(); + return _channel.invokeMethod('play'); + } + + Future stop() async { + final delegate = _delegate; + if (delegate != null) return delegate.stop(); + return _channel.invokeMethod('stop'); + } + + Future setProgress(double progress) async { + final delegate = _delegate; + if (delegate != null) return delegate.setProgress(progress); + return _channel.invokeMethod('setProgress', {'progress': progress}); + } + + Future setMode(String mode) async { + final delegate = _delegate; + if (delegate != null) { + await delegate.setMode(mode); + return; + } + await _channel.invokeMethod('setMode', {'mode': mode}); + } + + Future setLoop(bool loop) async { + final delegate = _delegate; + if (delegate != null) { + await delegate.setLoop(loop); + return; + } + await _channel.invokeMethod('setLoop', {'loop': loop}); + } + + Future?> manifest() async { + final delegate = _delegate; + if (delegate != null) return delegate.manifest(); + final result = await _channel.invokeMethod('manifest'); + if (result is Map) { + return result.cast(); + } + return null; + } +} diff --git a/modules/ensemble/lib/widget/lottie/lottie.dart b/modules/ensemble/lib/widget/lottie/lottie.dart index 58123d7ef..ae81b0613 100644 --- a/modules/ensemble/lib/widget/lottie/lottie.dart +++ b/modules/ensemble/lib/widget/lottie/lottie.dart @@ -4,12 +4,14 @@ import 'package:ensemble/framework/widget/widget.dart'; import 'package:ensemble/screen_controller.dart'; import 'package:ensemble/util/utils.dart'; import 'package:ensemble/widget/helpers/controllers.dart'; +import 'package:ensemble/widget/lottie/ensemble_dot_lottie_controller.dart'; import 'package:ensemble/widget/lottie/lottiestate.dart'; import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; -import 'package:flutter/cupertino.dart'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:lottie/lottie.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; class EnsembleLottie extends StatefulWidget with Invokable, HasController { @@ -30,69 +32,15 @@ class EnsembleLottie extends StatefulWidget @override Map methods() { - if (kIsWeb) { - // A little hacky way to check if html renderer is used as only html render would have the lottieController null. - // lottieController is null only for the html renderer. - // Cannot use js.context['flutterCanvasKit'] != null to check the html renderer as it requires importing dart:js which will break app in mobile runtime as dart:js is a web only package - final bool isNotHtml = _controller.lottieController != null; - - print(isNotHtml); - - return { - // Method to start animation in forward direction - 'forward': () { - if (isNotHtml) { - if (_controller.repeat) { - _controller.lottieController?.repeat(); - } else { - _controller.lottieController?.forward(); - } - } else { - _controller.lottieAction?.forward(); - } - }, - // Method to run animation in reverse direction - 'reverse': () { - if (isNotHtml) { - _controller.lottieController?.reverse(); - } else { - _controller.lottieAction?.reverse(); - } - }, - // Method to reset animation to initial position - 'reset': () { - if (isNotHtml) { - _controller.lottieController?.reset(); - } else { - _controller.lottieAction?.reset(); - } - }, - // Method to stop animation at current position - 'stop': () { - if (isNotHtml) { - _controller.lottieController?.stop(); - } else { - _controller.lottieAction?.stop(); - } - }, - }; - } - return { // Method to start animation in forward direction - 'forward': () { - if (_controller.repeat) { - _controller.lottieController?.repeat(); - } else { - _controller.lottieController?.forward(); - } - }, + 'forward': () => _controller.playForward(), // Method to run animation in reverse direction - 'reverse': () => _controller.lottieController?.reverse(), + 'reverse': () => _controller.playReverse(), // Method to reset animation to initial position - 'reset': () => _controller.lottieController?.reset(), + 'reset': () => _controller.resetAnimation(), // Method to stop animation at current position - 'stop': () => _controller.lottieController?.stop(), + 'stop': () => _controller.stopAnimation(), }; } @@ -139,6 +87,10 @@ mixin LottieAction on EWidgetState { } class LottieController extends BoxController { + LottieController() { + clipContent = true; + } + String source = ''; String? fit; EnsembleAction? onTap; @@ -146,50 +98,194 @@ class LottieController extends BoxController { bool repeat = true; bool autoPlay = true; - // lottieController and lottieAction are different things. - // lottieController is a AnimationController which is used to control animation and hook callbacks for all the platforms except web html renderer - // lottieAction is a mixin that is used to define all the methods for the html renderer. Cannot use normal AnimationController as html is rendered using iframe and doesn't use Lottie widget - AnimationController? lottieController; + // Drives playback after the dotlottie platform view is created. + EnsembleDotLottieController? lottieController; LottieAction? lottieAction; + // Tracks play direction so [fireOnPlay] can invoke onForward vs onReverse. + bool _playbackReverse = false; EnsembleAction? onForward; EnsembleAction? onReverse; EnsembleAction? onComplete; EnsembleAction? onStop; - // method to initialize the AnimationController lottieController - void initializeLottieController(LottieComposition composition) { - // Setting the duration of the animation once the lottie is loaded - lottieController?.duration = composition.duration; - - if (autoPlay) { - if (repeat) { - lottieController?.repeat(); - } else { - lottieController?.forward(); - } + void playForward() { + final player = lottieController; + if (player == null) return; + _playbackReverse = false; + player.setMode('forward'); + player.setLoop(repeat); + player.play(); + } + + void playReverse() { + final player = lottieController; + if (player == null) return; + _playbackReverse = true; + player.setMode('reverse'); + player.setLoop(repeat); + player.play(); + } + + void resetAnimation() { + final player = lottieController; + if (player == null) return; + player.stop(); + player.setProgress(0); + } + + void stopAnimation() => lottieController?.stop(); + + // Wired from DotLottieView onPlay / onComplete / onStop (replaces AnimationController status listeners). + void fireOnPlay(BuildContext context, EnsembleLottie widget) { + final action = _playbackReverse ? onReverse : onForward; + if (action == null) return; + ScreenController().executeAction( + context, + action, + event: EnsembleEvent(widget), + ); + } + + void fireOnComplete(BuildContext context, EnsembleLottie widget) { + if (onComplete == null) return; + ScreenController().executeAction( + context, + onComplete!, + event: EnsembleEvent(widget), + ); + } + + void fireOnStop(BuildContext context, EnsembleLottie widget) { + if (onStop == null) return; + ScreenController().executeAction( + context, + onStop!, + event: EnsembleEvent(widget), + ); + } +} + +/// Resolved source for [DotLottieView] / [DotLottieBytesView]. +/// +/// - JSON / URL → [stringSource] with matching [sourceType]. +/// - `.lottie` bundle / non-web remote URL → [byteSource] for the native player. +typedef ResolvedDotLottieSource = ({ + String sourceType, + String? stringSource, + Uint8List? byteSource, +}); + +/// Base64 data URL for platforms that load `.lottie` via `sourceType: 'url'`. +String dotLottieDataUrl(Uint8List bytes) => + 'data:application/octet-stream;base64,${base64Encode(bytes)}'; + +/// Resolves YAML [raw] source to [DotLottieView] `sourceType` + payload. +/// +/// Local paths come from [Utils.getLocalAssetFullPath] (pubspec keys like +/// `ensemble/apps/.../assets/foo.json`). We load via [rootBundle] because +/// DotLottieView's built-in `asset` mode expects `assets/`, which does +/// not match Ensemble's asset layout. +Future resolveDotLottieSource( + String raw, +) async { + final source = raw.trim(); + if (source.isEmpty) { + return (sourceType: '', stringSource: '', byteSource: null); + } + + if (source.startsWith('https://') || source.startsWith('http://')) { + final assetName = Utils.getAssetName(source); + if (Utils.isAssetAvailableLocally(assetName)) { + return _loadDotLottieBundle(Utils.getLocalAssetFullPath(assetName)); + } + if (!kIsWeb && _isDotLottieSource(source)) { + return _loadRemoteDotLottie(source); } + if (_needsInlineJsonUrl(source)) { + return _loadRemoteLottieJson(source); + } + return (sourceType: 'url', stringSource: source, byteSource: null); } - // Method to link statusListener with their respective events. - void addStatusListener(BuildContext context, EnsembleLottie widget) { - final animationStatusActionMap = { - AnimationStatus.forward: onForward, - AnimationStatus.reverse: onReverse, - AnimationStatus.dismissed: onStop, - AnimationStatus.completed: onComplete, - }; + return _loadDotLottieBundle(Utils.getLocalAssetFullPath(source)); +} - lottieController!.addStatusListener( - (status) { - if (animationStatusActionMap[status] != null) { - ScreenController().executeAction( - context, - animationStatusActionMap[status]!, - event: EnsembleEvent(widget), - ); - } - }, +/// Reads a bundle asset and returns a form dotlottie accepts. +/// `.json` → inline JSON string; `.lottie` → raw bytes for the native player. +Future _loadDotLottieBundle( + String bundleKey, +) async { + final data = await rootBundle.load(bundleKey); + final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); + if (bundleKey.toLowerCase().endsWith('.json')) { + return ( + sourceType: 'json', + stringSource: utf8.decode(bytes), + byteSource: null, ); } + return ( + sourceType: 'data', + stringSource: null, + byteSource: bytes, + ); +} + +Future _loadRemoteDotLottie(String source) async { + final response = await http.get(Uri.parse(source)); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception( + 'Failed to load dotLottie source ($source): ${response.statusCode}', + ); + } + return ( + sourceType: 'data', + stringSource: null, + byteSource: response.bodyBytes, + ); +} + +Future _loadRemoteLottieJson(String source) async { + final response = await http.get(Uri.parse(source)); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception( + 'Failed to load Lottie JSON source ($source): ${response.statusCode}', + ); + } + return ( + sourceType: 'json', + stringSource: utf8.decode(response.bodyBytes), + byteSource: null, + ); +} + +bool _isDotLottieSource(String source) { + final uri = Uri.tryParse(source); + return (uri?.path ?? source).toLowerCase().endsWith('.lottie'); +} + +bool _isJsonSource(String source) { + final uri = Uri.tryParse(source); + return (uri?.path ?? source).toLowerCase().endsWith('.json'); +} + +bool _needsInlineJsonUrl(String source) { + return !kIsWeb && + (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS) && + _isJsonSource(source); +} + +/// Animation aspect ratio (width / height) from top-level Lottie JSON `w` and `h`. +double? lottieAspectRatioFromJson(String jsonSource) { + try { + final map = jsonDecode(jsonSource); + final w = map['w']; + final h = map['h']; + if (w is num && h is num && h > 0) { + return w / h; + } + } catch (_) {} + return null; } diff --git a/modules/ensemble/lib/widget/lottie/native/lottiestate.dart b/modules/ensemble/lib/widget/lottie/native/lottiestate.dart index 27beb9237..58f0ef883 100644 --- a/modules/ensemble/lib/widget/lottie/native/lottiestate.dart +++ b/modules/ensemble/lib/widget/lottie/native/lottiestate.dart @@ -1,45 +1,24 @@ +import 'package:dotlottie_flutter/dotlottie_flutter.dart'; import 'package:ensemble/action/haptic_action.dart'; import 'package:ensemble/framework/event.dart'; import 'package:ensemble/framework/widget/widget.dart'; import 'package:ensemble/screen_controller.dart'; import 'package:ensemble/util/utils.dart'; import 'package:ensemble/widget/helpers/box_wrapper.dart'; +import 'package:ensemble/widget/lottie/dot_lottie_bytes_view.dart'; +import 'package:ensemble/widget/lottie/ensemble_dot_lottie_controller.dart'; import 'package:ensemble/widget/lottie/lottie.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import 'package:lottie/lottie.dart'; -import 'package:collection/collection.dart'; - -class LottieState extends EWidgetState - with SingleTickerProviderStateMixin { - late final AnimationController _animationController; - - Future _lottieDecoder(List bytes) { - return LottieComposition.decodeZip( - bytes, - filePicker: (files) { - return files.firstWhereOrNull( - (f) => f.name.startsWith('animations/') && f.name.endsWith('.json'), - ); - }, - ); - } - @override - void initState() { - super.initState(); - _animationController = AnimationController(vsync: this); - widget.controller.lottieController = _animationController; - widget.controller.addStatusListener(context, widget); - } +class LottieState extends EWidgetState { + bool _loadError = false; + // Updated after onLoad from dotlottie manifest (.lottie / URL). JSON uses w/h upfront. + double? _aspectRatio; @override void dispose() { - _animationController.stop(); - _animationController.dispose(); - - if (widget.controller.lottieController == _animationController) { - widget.controller.lottieController = null; - } + widget.controller.lottieController = null; super.dispose(); } @@ -48,9 +27,10 @@ class LottieState extends EWidgetState BoxFit? fit = Utils.getBoxFit(widget.controller.fit); Widget rtn = BoxWrapper( - widget: buildLottie(fit), + widget: buildLottie(context, fit), boxController: widget.controller, ignoresMargin: true, + // Width/height from YAML styles live on the controller; this child sets its own layout size. ignoresDimension: true); if (widget.controller.onTap != null) { rtn = GestureDetector( @@ -76,74 +56,287 @@ class LottieState extends EWidgetState return rtn; } - Widget buildLottie(BoxFit? fit) { + Widget buildLottie(BuildContext context, BoxFit? fit) { String source = widget.controller.source.trim(); - if (source.isNotEmpty) { - // if is URL - if (source.startsWith('https://') || source.startsWith('http://')) { - // If the asset is available locally, then use local path - String assetName = Utils.getAssetName(source); - if (Utils.isAssetAvailableLocally(assetName)) { - return Lottie.asset( - Utils.getLocalAssetFullPath(assetName), - controller: widget.controller.lottieController, - onLoaded: (composition) { - widget.controller.initializeLottieController(composition); - }, - decoder: widget.controller.source.endsWith('.lottie') - ? _lottieDecoder - : null, - width: widget.controller.width?.toDouble(), - height: widget.controller.height?.toDouble(), - repeat: widget.controller.repeat, - fit: fit, - errorBuilder: (context, error, stacktrace) => placeholderImage(), - ); + // Parent constraints (Column, Row, etc.) drive defaults when YAML width/height are omitted. + return LayoutBuilder( + builder: (context, constraints) { + if (source.isEmpty) { + return placeholderImage(context, constraints); } - return Lottie.network(widget.controller.source, - controller: widget.controller.lottieController, - decoder: widget.controller.source.endsWith('.lottie') - ? _lottieDecoder - : null, - onLoaded: (composition) { - widget.controller.initializeLottieController(composition); - }, - width: widget.controller.width?.toDouble(), - height: widget.controller.height?.toDouble(), - repeat: widget.controller.repeat, - fit: fit, - errorBuilder: (context, error, stacktrace) => placeholderImage()); - } - // else attempt local asset - else { - return Lottie.asset( - Utils.getLocalAssetFullPath(widget.controller.source), - controller: widget.controller.lottieController, - decoder: widget.controller.source.endsWith('.lottie') - ? _lottieDecoder - : null, - onLoaded: (composition) { - widget.controller.initializeLottieController(composition); + if (kIsWeb && + widget.controller.width == null && + widget.controller.height == null) { + final layoutSize = _layoutSize(context, constraints, 1.0); + print( + 'Lottie widget must have explicit width and height for html renderer in the browser (this includes the preview in the editor). You do not need to specify width or height for native apps or for canvaskit renderer. Defaulting to ${layoutSize.width} for width and ${layoutSize.height} for height'); + } + return FutureBuilder( + future: resolveDotLottieSource(source), + builder: (context, snapshot) { + if (_loadError) { + return placeholderImage(context, constraints); + } + if (snapshot.hasError) { + print('Failed to load Lottie animation from source: $source'); + + return placeholderImage(context, constraints); + } + if (!snapshot.hasData) { + return placeholderImage(context, constraints); + } + final resolved = snapshot.data!; + final hasString = resolved.stringSource != null && + resolved.stringSource!.isNotEmpty; + final hasBytes = resolved.byteSource != null; + if (!hasString && !hasBytes) { + return placeholderImage(context, constraints); + } + + final aspectRatio = _aspectRatio ?? + (resolved.sourceType == 'json' && hasString + ? lottieAspectRatioFromJson(resolved.stringSource!) + : null) ?? + 1.0; + + final layoutSize = _layoutSize(context, constraints, aspectRatio); + return _lottieBox( + constraints, + layoutSize, + aspectRatio, + _dotLottiePlayer( + context, + resolved, + fit, + layoutSize, + source, + ), + ); }, - width: widget.controller.width?.toDouble(), - height: widget.controller.height?.toDouble(), - repeat: widget.controller.repeat, - fit: fit, - errorBuilder: (context, error, stacktrace) => placeholderImage(), ); + }, + ); + } + + Widget _dotLottiePlayer( + BuildContext context, + ResolvedDotLottieSource resolved, + BoxFit? fit, + ({ + double? width, + double? height, + double? canvasWidth, + double? canvasHeight, + }) layoutSize, + String yamlSource, + ) { + final callbacks = ( + onViewCreated: (EnsembleDotLottieController player) { + widget.controller.lottieController = player; + }, + onLoad: () => _onLottieLoad(resolved.sourceType), + onPlay: () => widget.controller.fireOnPlay(context, widget), + onComplete: () => widget.controller.fireOnComplete(context, widget), + onStop: () => widget.controller.fireOnStop(context, widget), + onLoadError: () { + print('Failed to load Lottie animation from source: $yamlSource'); + if (mounted) setState(() => _loadError = true); + }, + ); + + final width = layoutSize.canvasWidth?.toInt(); + final height = layoutSize.canvasHeight?.toInt(); + final autoplay = widget.controller.autoPlay; + final loop = widget.controller.repeat; + + // Native players accept sourceType 'data' + bytes and expose HTTP failures in Dart. + if (!kIsWeb && resolved.byteSource != null) { + return DotLottieBytesView( + bytes: resolved.byteSource!, + autoplay: autoplay, + loop: loop, + fit: fit, + width: width, + height: height, + onViewCreated: callbacks.onViewCreated, + onLoad: callbacks.onLoad, + onPlay: callbacks.onPlay, + onComplete: callbacks.onComplete, + onStop: callbacks.onStop, + onLoadError: callbacks.onLoadError, + ); + } + + final sourceType = + resolved.byteSource != null ? 'url' : resolved.sourceType; + final source = resolved.byteSource != null + ? dotLottieDataUrl(resolved.byteSource!) + : resolved.stringSource!; + + return DotLottieView( + sourceType: sourceType, + source: source, + autoplay: autoplay, + loop: loop, + fit: fit, + width: width, + height: height, + onViewCreated: (controller) => + callbacks.onViewCreated(EnsembleDotLottieController.wrap(controller)), + onLoad: callbacks.onLoad, + onPlay: callbacks.onPlay, + onComplete: callbacks.onComplete, + onStop: callbacks.onStop, + onLoadError: callbacks.onLoadError, + ); + } + + Future _onLottieLoad(String sourceType) async { + // JSON aspect ratio is parsed before build; manifest is for .lottie and remote URL sources. + if (_aspectRatio != null || sourceType == 'json') return; + final manifest = await widget.controller.lottieController?.manifest(); + final ratio = _aspectRatioFromManifest(manifest); + if (ratio != null && mounted) { + setState(() => _aspectRatio = ratio); + } + } + + double? _aspectRatioFromManifest(Map? manifest) { + if (manifest == null) return null; + final animations = manifest['animations']; + if (animations is! List || animations.isEmpty) return null; + final first = animations.first; + if (first is! Map) return null; + final w = first['width'] ?? first['w']; + final h = first['height'] ?? first['h']; + if (w is num && h is num && h > 0) { + return w / h; + } + return null; + } + + /// Computes layout and canvas sizes from YAML styles + parent [constraints]. + /// + /// - `width` / `height`: Flutter widget box (SizedBox / AspectRatio). + /// - `canvasWidth` / `canvasHeight`: passed to [DotLottieView] for the native player surface. + /// When YAML height is omitted, canvas height is derived from width and [aspectRatio]. + ({ + double? width, + double? height, + double? canvasWidth, + double? canvasHeight, + }) _layoutSize( + BuildContext context, + BoxConstraints constraints, + double aspectRatio, + ) { + final styleWidth = widget.controller.width?.toDouble(); + final styleHeight = widget.controller.height?.toDouble(); + + if (kIsWeb) { + // Web defaults when YAML styles omit dimensions (see warning print in buildLottie). + final width = styleWidth ?? 250; + final height = styleHeight ?? 250; + return ( + width: width, + height: height, + canvasWidth: width, + canvasHeight: height, + ); + } + + double? width = styleWidth; + final height = styleHeight; + + // YAML height only: derive width from the animation so the visual box, clip, + // and rounded corners match the rendered content instead of the full parent. + if (height != null && width == null) { + final derivedWidth = height * aspectRatio; + width = + constraints.maxWidth.isFinite && derivedWidth > constraints.maxWidth + ? constraints.maxWidth + : derivedWidth; + } + + // Use parent max width when both YAML dimensions are omitted (e.g. Column). Do + // not use screen width when maxWidth is infinite (e.g. Row) — that caused overflow. + if (height == null && width == null && constraints.maxWidth.isFinite) { + width = constraints.maxWidth; + } + + return ( + width: width, + height: height, + canvasWidth: width, + canvasHeight: height ?? (width != null ? width / aspectRatio : null), + ); + } + + /// Wraps [child] in a bounded Flutter box. DotLottieView is a platform view and cannot + /// layout with unbounded constraints (unlike the old Lottie composition widget). + Widget _lottieBox( + BoxConstraints constraints, + ({ + double? width, + double? height, + double? canvasWidth, + double? canvasHeight, + }) layoutSize, + double aspectRatio, + Widget child, + ) { + final width = layoutSize.width; + final height = layoutSize.height; + + if (width != null && height != null) { + return SizedBox(width: width, height: height, child: child); + } + + if (height != null) { + final boxWidth = width ?? height * aspectRatio; + return SizedBox(width: boxWidth, height: height, child: child); + } + + if (width != null) { + // YAML height omitted: bounded parent height fills vertically; else AspectRatio. + if (constraints.hasBoundedHeight) { + return SizedBox(width: width, child: child); + } + return SizedBox( + width: width, + child: AspectRatio(aspectRatio: aspectRatio, child: child), + ); + } + + if (constraints.maxWidth.isFinite) { + final w = constraints.maxWidth; + if (constraints.hasBoundedHeight) { + return SizedBox(width: w, child: child); } + return SizedBox( + width: w, + child: AspectRatio(aspectRatio: aspectRatio, child: child), + ); } + + // Both YAML dimensions omitted and parent width is unbounded (e.g. bare Row child). return SizedBox( - width: widget.controller.width?.toDouble(), - height: widget.controller.height?.toDouble(), + width: 200, + height: 200, + child: child, ); } - Widget placeholderImage() { - return SizedBox( - width: widget.controller.width?.toDouble(), - height: widget.controller.height?.toDouble(), - child: Image.asset('assets/images/img_placeholder.png', - package: 'ensemble')); + Widget placeholderImage(BuildContext context, BoxConstraints constraints) { + final layoutSize = _layoutSize(context, constraints, _aspectRatio ?? 1.0); + return _lottieBox( + constraints, + layoutSize, + _aspectRatio ?? 1.0, + Image.asset( + 'assets/images/img_placeholder.png', + package: 'ensemble', + ), + ); } } diff --git a/modules/ensemble/lib/widget/lottie/web/lottiestate.dart b/modules/ensemble/lib/widget/lottie/web/lottiestate.dart index 765e65f7d..eb9db2226 100644 --- a/modules/ensemble/lib/widget/lottie/web/lottiestate.dart +++ b/modules/ensemble/lib/widget/lottie/web/lottiestate.dart @@ -1,328 +1,2 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:math'; -import 'dart:ui_web' as ui; -import 'package:ensemble/framework/error_handling.dart'; -import 'package:ensemble/framework/event.dart'; -import 'package:ensemble/framework/widget/widget.dart'; -import 'package:ensemble/screen_controller.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble/widget/helpers/box_wrapper.dart'; -import 'package:ensemble/widget/helpers/widgets.dart'; -import 'package:ensemble/widget/lottie/lottie.dart'; -import 'package:ensemble/widget/widget_util.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:http/http.dart'; -import 'package:js_widget/js_widget.dart'; -import 'dart:js' as js; -import 'dart:html' as html; -import 'package:lottie/lottie.dart'; -import 'package:collection/collection.dart'; - -class LottieState extends EWidgetState - with SingleTickerProviderStateMixin, LottieAction { - String id = 'lottie_${Random().nextInt(900000) + 100000}'; - final isCanvasKit = js.context['flutterCanvasKit'] != null; - late String divId; - int lastEventId = -1; - @override - void initState() { - super.initState(); - divId = widget.controller.id ?? id; - if (isCanvasKit) { - widget.controller - ..lottieController = AnimationController(vsync: this) - ..addStatusListener(context, widget); - } - } - - // Binding LottieActions which are used specifically for html renderer as it is rendered using JS - @override - void didChangeDependencies() { - super.didChangeDependencies(); - widget.controller.lottieAction = this; - } - - @override - void didUpdateWidget(covariant EnsembleLottie oldWidget) { - super.didUpdateWidget(oldWidget); - widget.controller.lottieAction = this; - } - - @override - void forward() => html.window.postMessage('forward_$divId', "*"); - @override - void reset() => html.window.postMessage('reset_$divId', "*"); - @override - void reverse() => html.window.postMessage('reverse_$divId', "*"); - @override - void stop() => html.window.postMessage('stop_$divId', "*"); - @override - void dispose() { - html.window.close(); // To prevent memory leaks - widget.controller.lottieController?.dispose(); - super.dispose(); - } - - @override - Widget buildWidget(BuildContext context) { - BoxFit? fit = Utils.getBoxFit(widget.controller.fit); - - Widget rtn = BoxWrapper( - widget: isCanvasKit ? buildLottieCanvas(fit) : buildLottieHtml(fit), - boxController: widget.controller, - ignoresMargin: true, - ignoresDimension: true); - if (widget.controller.onTap != null) { - rtn = GestureDetector( - child: rtn, - onTap: () => ScreenController().executeAction( - context, widget.controller.onTap!, - event: EnsembleEvent(widget))); - } - if (widget.controller.margin != null) { - rtn = Padding(padding: widget.controller.margin!, child: rtn); - } - return rtn; - } - - Future _lottieDecoder(List bytes) { - return LottieComposition.decodeZip( - bytes, - filePicker: (files) { - return files.firstWhereOrNull( - (f) => f.name.startsWith('animations/') && f.name.endsWith('.json'), - ); - }, - ); - } - - // Render this when the runtime is flutter web with html renderer - Widget buildLottieHtml(BoxFit? fit) { - String source = widget.controller.source.trim(); - double width = widget.controller.width?.toDouble() ?? 250; - double height = widget.controller.height?.toDouble() ?? 250; - bool repeat = widget.controller.repeat; - bool autoPlay = widget.controller.autoPlay; - if (widget.controller.width == null || widget.controller.height == null) { - print( - 'Lottie widget must have explicit width and height for html renderer in the browser (this includes the preview in the editor). You do not need to specify width or height for native apps or for canvaskit renderer. Defaulting to $width for width and $height for height'); - } - - if (source.isNotEmpty) { - // image binding is tricky. When the URL has not been resolved - // the image will throw exception. We have to use a permanent placeholder - // until the binding engages - // HTML & JS code for the web html renderer - final htmlString = ''' - - - - - - - - - - - -'''; - // Defining the constraints and layout for the iframe in flutter side - final html.IFrameElement iFrame = html.IFrameElement() - ..width = '$width' - ..height = '$height' - ..srcdoc = htmlString - ..style.border = 'none' - ..onLoad; - // Event listener for the messages that are sent from JS to Dart - html.window.onMessage.listen( - (event) async { - final String data = event.data; - // Need to check if the data is in json format as there are also other events from JS - if (data.contains('{')) { - final json = jsonDecode(data); - // Segregating the latest event from old events using then html tag and the id which is just a counter which increments by 1 for each event - if (lastEventId != json['id'] && divId == json['tag']) { - lastEventId = json['id']; - // Mapping the events to their respective callbacks - if (json['data'] == "onForward" && - widget.controller.onForward != null) { - ScreenController().executeAction( - context, - widget.controller.onForward!, - event: EnsembleEvent(widget), - ); - } - if (json['data'] == "onComplete" && - widget.controller.onComplete != null) { - ScreenController().executeAction( - context, - widget.controller.onComplete!, - event: EnsembleEvent(widget), - ); - } - if (json['data'] == "onStop" && - widget.controller.onStop != null) { - ScreenController().executeAction( - context, - widget.controller.onStop!, - event: EnsembleEvent(widget), - ); - } - if (json['data'] == "onReverse" && - widget.controller.onReverse != null) { - ScreenController().executeAction( - context, - widget.controller.onReverse!, - event: EnsembleEvent(widget), - ); - } - } - } - }, - ); - - iFrame.style.pointerEvents = "none"; - - // Registering the iframe in the flutter widget tree - ui.platformViewRegistry.registerViewFactory( - divId, - (int viewId) => iFrame, - ); - // 24 and 16 somehow are the minimum numbers to remove the slider. Without adding them, there would be scroll sliders even when the width of iframe is exactly same as that of widget. - return SizedBox( - width: width + 24, - height: height + 16, - child: AbsorbPointer( - child: HtmlElementView(viewType: divId), - ), // Rendering the iframe - ); - } - return blankPlaceholder(); - } - - // Render this when the runtime is flutter web with canvas-kit renderer - Widget buildLottieCanvas(BoxFit? fit) { - String source = widget.controller.source.trim(); - if (source.isNotEmpty) { - // if is URL - if (source.startsWith('https://') || source.startsWith('http://')) { - // If the asset is available locally, then use local path - String assetName = Utils.getAssetName(source); - if (Utils.isAssetAvailableLocally(assetName)) { - return Lottie.asset(Utils.getLocalAssetFullPath(assetName), - controller: widget.controller.lottieController, - decoder: widget.controller.source.endsWith('.lottie') - ? _lottieDecoder - : null, - onLoaded: (LottieComposition composition) { - widget.controller.initializeLottieController(composition); - }, - width: widget.controller.width?.toDouble(), - height: widget.controller.height?.toDouble(), - repeat: widget.controller.repeat, - fit: fit, - errorBuilder: (context, error, stacktrace) => placeholderImage()); - } - // image binding is tricky. When the URL has not been resolved - // the image will throw exception. We have to use a permanent placeholder - // until the binding engages - return Lottie.network( - widget.controller.source, - controller: widget.controller.lottieController, - decoder: widget.controller.source.endsWith('.lottie') - ? _lottieDecoder - : null, - onLoaded: (composition) { - widget.controller.initializeLottieController(composition); - }, - width: widget.controller.width?.toDouble(), - height: widget.controller.height?.toDouble(), - repeat: widget.controller.repeat, - fit: fit, - errorBuilder: (context, error, stacktrace) => placeholderImage(), - ); - } - // else attempt local asset - - return Lottie.asset(Utils.getLocalAssetFullPath(widget.controller.source), - controller: widget.controller.lottieController, - decoder: widget.controller.source.endsWith('.lottie') - ? _lottieDecoder - : null, - onLoaded: (LottieComposition composition) { - widget.controller.initializeLottieController(composition); - }, - width: widget.controller.width?.toDouble(), - height: widget.controller.height?.toDouble(), - repeat: widget.controller.repeat, - fit: fit, - errorBuilder: (context, error, stacktrace) => placeholderImage()); - } - return blankPlaceholder(); - } - - Widget placeholderImage() { - return SizedBox( - width: widget.controller.width?.toDouble(), - height: widget.controller.height?.toDouble(), - child: Image.asset('assets/images/img_placeholder.png', - package: 'ensemble')); - } - - Widget blankPlaceholder() => SizedBox( - width: widget.controller.width?.toDouble(), - height: widget.controller.height?.toDouble(), - ); -} +// Web uses dotlottie_flutter's platform view; same implementation as native. +export '../native/lottiestate.dart'; diff --git a/modules/ensemble/pubspec.yaml b/modules/ensemble/pubspec.yaml index a53313f16..fb7a52818 100644 --- a/modules/ensemble/pubspec.yaml +++ b/modules/ensemble/pubspec.yaml @@ -56,7 +56,7 @@ dependencies: carousel_slider: ^5.0.0 fluttertoast: ^8.2.14 video_player: ^2.6.1 - lottie: ^3.0.0 + dotlottie_flutter: ^0.1.3 cookie_jar: ^4.0.8 js_widget: ^1.0.4 flutter_markdown: ^0.7.7+1 diff --git a/starter/README.md b/starter/README.md index fe432d38f..034f3f03d 100644 --- a/starter/README.md +++ b/starter/README.md @@ -11,6 +11,15 @@ This starter project enables running and deploying Ensemble-powered Apps across ### Initial Setup - Review `/ensemble/ensemble.properties`. Update the appId as needed - this is your app's bundle ID in the format of . e.g. `com.ensembleui.myfirstapp` (all lowercase, no special characters). - Run `flutter create --org com.ensembleui --project-name starter --platform=ios,android,web .` (note the period at the end). If you modified the appId, make sure the org and project name match the bundle ID. +- **Android JitPack Setup (for dotlottie support):** To allow dotlottie-android to download, ensure you have jitpack inside your build.gradle/build.gradle.kts file: + + + ```kotlin + // for .kts + maven { url = uri("https://jitpack.io") } + // for groovy + maven { url 'https://jitpack.io' } + ``` - **Monorepo checkout:** From the repository root, run `melos bootstrap` before building starter. This links local `modules/` and `packages/` paths instead of the git URLs in `pubspec.yaml`. Re-run it after changing dependencies in any module. - Run `flutter pub upgrade`. Run this occasionally when the Ensemble framework has been updated. - Run the App with `flutter run`. If you currently have a running iOS or Android emulator, the command will prompt for a selection, otherwise the App will be opened in the web browser. diff --git a/starter/android/build.gradle b/starter/android/build.gradle index 777aecd25..fca873e86 100644 --- a/starter/android/build.gradle +++ b/starter/android/build.gradle @@ -3,6 +3,7 @@ buildscript { repositories { google() mavenCentral() + maven { url 'https://jitpack.io' } } dependencies { @@ -15,6 +16,7 @@ allprojects { repositories { google() mavenCentral() + maven { url 'https://jitpack.io' } } }