diff --git a/packages/flame_devtools/lib/widgets/component_snapshot.dart b/packages/flame_devtools/lib/widgets/component_snapshot.dart index 72ed1841c0f..bc6c823023d 100644 --- a/packages/flame_devtools/lib/widgets/component_snapshot.dart +++ b/packages/flame_devtools/lib/widgets/component_snapshot.dart @@ -1,4 +1,6 @@ -import 'package:flame/flame.dart'; +import 'dart:convert'; +import 'dart:ui' as ui; + import 'package:flame/widgets.dart'; import 'package:flame_devtools/repository.dart'; import 'package:flutter/material.dart' hide Image; @@ -52,7 +54,13 @@ class _ComponentSnapshotState extends State { } } -class Base64Image extends StatelessWidget { +/// Displays an image decoded from a base64 data string. +/// +/// The decoded [ui.Image] is held by this widget and disposed when the widget +/// is removed or when [base64] / [imageId] changes — without going through +/// the global Flame images cache, so the same component id can show +/// different snapshots over time without serving a stale cached frame. +class Base64Image extends StatefulWidget { const Base64Image({ required this.base64, required this.imageId, @@ -62,23 +70,68 @@ class Base64Image extends StatelessWidget { final String base64; final String imageId; + @override + State createState() => _Base64ImageState(); +} + +class _Base64ImageState extends State { + late Future _imageFuture; + ui.Image? _image; + + @override + void initState() { + super.initState(); + _imageFuture = _decode(widget.base64); + } + + @override + void didUpdateWidget(Base64Image oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.base64 != widget.base64 || + oldWidget.imageId != widget.imageId) { + _disposeImage(); + _imageFuture = _decode(widget.base64); + } + } + + @override + void dispose() { + _disposeImage(); + super.dispose(); + } + + Future _decode(String base64Data) async { + final commaIndex = base64Data.indexOf(','); + final payload = commaIndex == -1 + ? base64Data + : base64Data.substring(commaIndex + 1); + final bytes = base64.decode(payload); + final image = await decodeImageFromList(bytes); + if (mounted) { + _image = image; + } else { + image.dispose(); + } + return image; + } + + void _disposeImage() { + _image?.dispose(); + _image = null; + } + @override Widget build(BuildContext context) { - final imageFuture = Flame.images.fromBase64( - imageId, - base64, - ); - return FutureBuilder( - future: imageFuture, + return FutureBuilder( + future: _imageFuture, builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.connectionState == ConnectionState.done && + snapshot.hasData) { return SizedBox( width: 200, height: 200, child: SpriteWidget( - sprite: Sprite( - snapshot.data!, - ), + sprite: Sprite(snapshot.data!), ), ); } diff --git a/packages/flame_devtools/lib/widgets/component_tree.dart b/packages/flame_devtools/lib/widgets/component_tree.dart index 0e1fb7abee3..7f791172a33 100644 --- a/packages/flame_devtools/lib/widgets/component_tree.dart +++ b/packages/flame_devtools/lib/widgets/component_tree.dart @@ -59,45 +59,41 @@ class ComponentTreeSection extends ConsumerWidget { ), ), Expanded( - child: SingleChildScrollView( - child: TreeView.simple( - showRootNode: false, - shrinkWrap: true, - indentation: const Indentation( - color: Colors.blue, - style: IndentStyle.roundJoint, - ), - onTreeReady: (controller) => - controller.expandAllChildren(controller.tree), - padding: const EdgeInsets.only(left: 20), - expansionIndicatorBuilder: (context, node) => node.isLeaf - ? NoExpansionIndicator(tree: node) - : ChevronIndicator.rightDown( - tree: node, - alignment: Alignment.centerLeft, - ), - builder: (context, node) { - return Padding( - padding: node.isLeaf - ? EdgeInsets.zero - : const EdgeInsets.only(left: 20), - child: ListTile( - key: Key( - node.data?.id.toString() ?? node.key, - ), - selected: node == selectedTreeNode, - selectedColor: theme.colorScheme.primary, - title: Text(node.data!.name), - subtitle: Text(node.data!.id.toString()), - onTap: () { - ref.read(selectedTreeNodeProvider.notifier).state = - node; - }, - ), - ); - }, - tree: loadedModel.treeRoot, + child: TreeView.simple( + showRootNode: false, + indentation: const Indentation( + color: Colors.blue, + style: IndentStyle.roundJoint, ), + onTreeReady: (controller) => + controller.expandAllChildren(controller.tree), + padding: const EdgeInsets.only(left: 20), + expansionIndicatorBuilder: (context, node) => node.isLeaf + ? NoExpansionIndicator(tree: node) + : ChevronIndicator.rightDown( + tree: node, + alignment: Alignment.centerLeft, + ), + builder: (context, node) { + return Padding( + padding: node.isLeaf + ? EdgeInsets.zero + : const EdgeInsets.only(left: 20), + child: ListTile( + key: Key( + node.data?.id.toString() ?? node.key, + ), + selected: node == selectedTreeNode, + selectedColor: theme.colorScheme.primary, + title: Text(node.data!.name), + subtitle: Text(node.data!.id.toString()), + onTap: () { + ref.read(selectedTreeNodeProvider.notifier).state = node; + }, + ), + ); + }, + tree: loadedModel.treeRoot, ), ), ],