diff --git a/packages/flutter_tools/lib/src/commands/widget_preview.dart b/packages/flutter_tools/lib/src/commands/widget_preview.dart index 97d2b78d8b..2b52cb458c 100644 --- a/packages/flutter_tools/lib/src/commands/widget_preview.dart +++ b/packages/flutter_tools/lib/src/commands/widget_preview.dart @@ -519,10 +519,12 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C }) { final List assets = rootManifest.assets.map(transformAssetsEntry).toList(); - final List fonts = - rootManifest.fonts.map((Font font) { - return Font(font.familyName, font.fontAssets.map(transformFontAsset).toList()); - }).toList(); + final List fonts = [ + ...widgetPreviewManifest.fonts, + ...rootManifest.fonts.map((Font font) { + return Font(font.familyName, font.fontAssets.map(transformFontAsset).toList()); + }), + ]; final List shaders = rootManifest.shaders.map(transformAssetUri).toList(); @@ -576,6 +578,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C '--directory', widgetPreviewScaffoldProject.directory.path, 'flutter_lints', + 'stack_trace', ], context: PubContext.pubAdd, command: pubAdd, diff --git a/packages/flutter_tools/lib/src/widget_preview/preview_code_generator.dart b/packages/flutter_tools/lib/src/widget_preview/preview_code_generator.dart index a7a203f8d1..9586f2d4dd 100644 --- a/packages/flutter_tools/lib/src/widget_preview/preview_code_generator.dart +++ b/packages/flutter_tools/lib/src/widget_preview/preview_code_generator.dart @@ -103,6 +103,10 @@ class PreviewCodeGenerator { preview.wrapperLibraryUri, ).call([previewWidget]); } + previewWidget = + Method((MethodBuilder previewBuilder) { + previewBuilder.body = previewWidget.code; + }).closure; previewExpressions.add( refer( 'WidgetPreview', @@ -115,7 +119,7 @@ class PreviewCodeGenerator { key: PreviewDetails.kTextScaleFactor, property: preview.textScaleFactor, ), - 'child': previewWidget, + 'builder': previewWidget, }), ); } diff --git a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/controls.dart.tmpl b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/controls.dart.tmpl index 65390505a9..1e0125645b 100644 --- a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/controls.dart.tmpl +++ b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/controls.dart.tmpl @@ -26,10 +26,7 @@ class _WidgetPreviewIconButton extends StatelessWidget { ), child: IconButton( onPressed: onPressed, - icon: Icon( - color: Colors.white, - icon, - ), + icon: Icon(color: Colors.white, icon), ), ), ); @@ -42,9 +39,11 @@ class ZoomControls extends StatelessWidget { const ZoomControls({ super.key, required TransformationController transformationController, + required this.enabled, }) : _transformationController = transformationController; final TransformationController _transformationController; + final bool enabled; @override Widget build(BuildContext context) { @@ -53,23 +52,19 @@ class ZoomControls extends StatelessWidget { children: [ _WidgetPreviewIconButton( tooltip: 'Zoom in', - onPressed: _zoomIn, + onPressed: enabled ? _zoomIn : null, icon: Icons.zoom_in, ), - const SizedBox( - width: 10, - ), + const SizedBox(width: 10), _WidgetPreviewIconButton( tooltip: 'Zoom out', - onPressed: _zoomOut, + onPressed: enabled ? _zoomOut : null, icon: Icons.zoom_out, ), - const SizedBox( - width: 10, - ), + const SizedBox(width: 10), _WidgetPreviewIconButton( tooltip: 'Reset zoom', - onPressed: _reset, + onPressed: enabled ? _reset : null, icon: Icons.refresh, ), ], diff --git a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/utils.dart.tmpl b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/utils.dart.tmpl index 95912a82b2..fc8d5ba4d4 100644 --- a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/utils.dart.tmpl +++ b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/utils.dart.tmpl @@ -4,6 +4,14 @@ import 'package:flutter/material.dart'; +/// Returns a [TextStyle] with [FontFeature.proportionalFigures] applied to +/// fix blurry text. +TextStyle fixBlurryText(TextStyle style) { + return style.copyWith( + fontFeatures: [const FontFeature.proportionalFigures()], + ); +} + /// A basic vertical spacer. class VerticalSpacer extends StatelessWidget { /// Creates a basic vertical spacer. @@ -11,8 +19,6 @@ class VerticalSpacer extends StatelessWidget { @override Widget build(BuildContext context) { - return const SizedBox( - height: 10, - ); + return const SizedBox(height: 10); } } diff --git a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview.dart.tmpl b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview.dart.tmpl index 1da5546e47..9010004947 100644 --- a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview.dart.tmpl +++ b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview.dart.tmpl @@ -15,10 +15,10 @@ import 'package:flutter/widgets.dart'; /// previews. // TODO(bkonyi): link to actual documentation when available. class WidgetPreview { - /// Wraps [child] in a [WidgetPreview] instance that applies some set of + /// Wraps [builder] in a [WidgetPreview] instance that applies some set of /// properties. const WidgetPreview({ - required this.child, + required this.builder, this.name, this.width, this.height, @@ -30,22 +30,22 @@ class WidgetPreview { /// If not provided, no name will be associated with the preview. final String? name; - /// The [Widget] to be rendered in the preview. - final Widget child; + /// A callback to build the [Widget] to be rendered in the preview. + final Widget Function() builder; - /// Artificial width constraint to be applied to the [child]. + /// Artificial width constraint to be applied to the [Widget] returned by [builder]. /// /// If not provided, the previewed widget will attempt to set its own width /// constraints and may result in an unbounded constraint error. final double? width; - /// Artificial height constraint to be applied to the [child]. + /// Artificial height constraint to be applied to the [Widget] returned by [builder]. /// /// If not provided, the previewed widget will attempt to set its own height /// constraints and may result in an unbounded constraint error. final double? height; - /// Applies font scaling to text within the [child]. + /// Applies font scaling to text within the [Widget] returned by [builder]. /// /// If not provided, the default text scaling factor provided by [MediaQuery] /// will be used. diff --git a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl index 3d71be219a..8687b72756 100644 --- a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl +++ b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl @@ -2,21 +2,117 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:math' as math; + import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; +import 'package:stack_trace/stack_trace.dart'; + import 'controls.dart'; import 'generated_preview.dart'; import 'utils.dart'; import 'widget_preview.dart'; +/// Displayed when an unhandled exception is thrown when initializing the widget +/// tree for a preview (i.e., before the build phase). +/// +/// Provides users with details about the thrown exception, including the exception +/// contents and a scrollable stack trace. +class _WidgetPreviewErrorWidget extends StatelessWidget { + _WidgetPreviewErrorWidget({ + required this.error, + required StackTrace stackTrace, + required this.size, + }) : trace = Trace.from(stackTrace).terse; + + /// The [Object] that was thrown, resulting in an unhandled exception. + final Object error; + + /// The stack trace identifying where [error] was thrown from. + final Trace trace; + + /// The size of the error widget. + final Size size; + + @override + Widget build(BuildContext context) { + final TextStyle boldStyle = fixBlurryText( + TextStyle(fontWeight: FontWeight.bold), + ); + + return SizedBox( + height: size.height, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Failed to initialize widget tree: ', + style: boldStyle, + ), + // TODO(bkonyi): use monospace font + TextSpan(text: error.toString()), + ], + ), + ), + Text('Stacktrace:', style: boldStyle), + // TODO(bkonyi): use monospace font + SelectableText.rich( + TextSpan(children: _formatFrames(trace.frames)), + ), + ], + ), + ), + ); + } + + List _formatFrames(List frames) { + // Figure out the longest path so we know how much to pad. + final int longest = frames + .map((frame) => frame.location.length) + .fold(0, math.max); + + final TextStyle linkTextStyle = fixBlurryText( + TextStyle( + decoration: TextDecoration.underline, + // TODO(bkonyi): this color scheme is from DevTools and should be responsive + // to changes in the previewer theme. + color: const Color(0xFF1976D2), + ), + ); + + // Print out the stack trace nicely formatted. + return frames.map((frame) { + if (frame is UnparsedFrame) return TextSpan(text: '$frame\n'); + return TextSpan( + children: [ + TextSpan( + text: frame.location, + style: linkTextStyle, + recognizer: + TapGestureRecognizer() + ..onTap = () { + // TODO(bkonyi): notify IDEs to navigate to the source location via DTD. + }, + ), + TextSpan(text: ' ' * (longest - frame.location.length)), + const TextSpan(text: ' '), + TextSpan(text: '${frame.member}\n'), + ], + ); + }).toList(); + } +} + class WidgetPreviewWidget extends StatefulWidget { - const WidgetPreviewWidget({ - super.key, - required this.preview, - }); + const WidgetPreviewWidget({super.key, required this.preview}); final WidgetPreview preview; @@ -43,19 +139,31 @@ class _WidgetPreviewWidgetState extends State { maxHeight: previewerConstraints.maxHeight / 2.0, ); - Widget preview = _WidgetPreviewWrapper( + bool errorThrownDuringTreeConstruction = false; + Widget preview; + // Catch any unhandled exceptions and display an error widget instead of taking + // down the entire preview environment. + try { + preview = widget.preview.builder(); + } on Object catch (error, stackTrace) { + errorThrownDuringTreeConstruction = true; + preview = _WidgetPreviewErrorWidget( + error: error, + stackTrace: stackTrace, + size: maxSizeConstraints.biggest, + ); + } + + preview = _WidgetPreviewWrapper( previewerConstraints: maxSizeConstraints, child: SizedBox( width: widget.preview.width, height: widget.preview.height, - child: widget.preview.child, + child: preview, ), ); - preview = MediaQuery( - data: _buildMediaQueryOverride(), - child: preview, - ); + preview = MediaQuery(data: _buildMediaQueryOverride(), child: preview); preview = Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -63,9 +171,8 @@ class _WidgetPreviewWidgetState extends State { if (widget.preview.name != null) ...[ Text( widget.preview.name!, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w300, + style: fixBlurryText( + TextStyle(fontSize: 16, fontWeight: FontWeight.w300), ), ), const VerticalSpacer(), @@ -80,6 +187,9 @@ class _WidgetPreviewWidgetState extends State { children: [ ZoomControls( transformationController: transformationController, + // If an unhandled exception was caught and we're displaying an error + // widget, these controls should be disabled. + enabled: !errorThrownDuringTreeConstruction, ), ], ), @@ -90,10 +200,7 @@ class _WidgetPreviewWidgetState extends State { padding: const EdgeInsets.all(16.0), child: Card( child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 16.0, - horizontal: 16.0, - ), + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0), child: preview, ), ), @@ -109,13 +216,13 @@ class _WidgetPreviewWidgetState extends State { ); } - var size = Size(widget.preview.width ?? mediaQueryData.size.width, - widget.preview.height ?? mediaQueryData.size.height); + var size = Size( + widget.preview.width ?? mediaQueryData.size.width, + widget.preview.height ?? mediaQueryData.size.height, + ); if (widget.preview.width != null || widget.preview.height != null) { - mediaQueryData = mediaQueryData.copyWith( - size: size, - ); + mediaQueryData = mediaQueryData.copyWith(size: size); } return mediaQueryData; @@ -137,8 +244,11 @@ class WidgetPreviewerWindowConstraints extends InheritedWidget { final BoxConstraints constraints; static BoxConstraints getRootConstraints(BuildContext context) { - final result = context - .dependOnInheritedWidgetOfExactType(); + final result = + context + .dependOnInheritedWidgetOfExactType< + WidgetPreviewerWindowConstraints + >(); assert( result != null, 'No WidgetPreviewerWindowConstraints founds in context', @@ -207,8 +317,8 @@ class _WidgetPreviewWrapperBox extends RenderShiftedBox { _WidgetPreviewWrapperBox({ required RenderBox? child, required BoxConstraints previewerConstraints, - }) : _previewerConstraints = previewerConstraints, - super(child); + }) : _previewerConstraints = previewerConstraints, + super(child); BoxConstraints _constraintOverride = const BoxConstraints(); BoxConstraints _previewerConstraints; @@ -222,10 +332,7 @@ class _WidgetPreviewWrapperBox extends RenderShiftedBox { } @override - void layout( - Constraints constraints, { - bool parentUsesSize = false, - }) { + void layout(Constraints constraints, {bool parentUsesSize = false}) { if (child != null && constraints is BoxConstraints) { double minInstrinsicHeight; try { @@ -241,14 +348,12 @@ class _WidgetPreviewWrapperBox extends RenderShiftedBox { // the previewer. In this case, apply finite constraints (e.g., the // constraints for the root of the previewer). Otherwise, use the // widget's actual constraints. - _constraintOverride = minInstrinsicHeight == 0 - ? _previewerConstraints - : const BoxConstraints(); + _constraintOverride = + minInstrinsicHeight == 0 + ? _previewerConstraints + : const BoxConstraints(); } - super.layout( - constraints, - parentUsesSize: parentUsesSize, - ); + super.layout(constraints, parentUsesSize: parentUsesSize); } @override @@ -259,10 +364,7 @@ class _WidgetPreviewWrapperBox extends RenderShiftedBox { return; } final updatedConstraints = _constraintOverride.enforce(constraints); - child.layout( - updatedConstraints, - parentUsesSize: true, - ); + child.layout(updatedConstraints, parentUsesSize: true); size = constraints.constrain(child.size); } } @@ -308,9 +410,7 @@ class PreviewAssetBundle extends PlatformAssetBundle { /// the preview scaffold project which prevents us from being able to use hot /// restart to iterate on this file. Future mainImpl() async { - runApp( - _WidgetPreviewScaffold(), - ); + runApp(_WidgetPreviewScaffold()); } class _WidgetPreviewScaffold extends StatelessWidget { @@ -321,7 +421,7 @@ class _WidgetPreviewScaffold extends StatelessWidget { final List previewList = previews(); Widget previewView; if (previewList.isEmpty) { - previewView = const Column( + previewView = Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Center( @@ -329,9 +429,9 @@ class _WidgetPreviewScaffold extends StatelessWidget { // with Widget Previews. child: Text( 'No previews available', - style: TextStyle(color: Colors.white), + style: fixBlurryText(TextStyle(color: Colors.white)), ), - ) + ), ], ); } else { @@ -343,7 +443,8 @@ class _WidgetPreviewScaffold extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - for (final WidgetPreview preview in previewList) WidgetPreviewWidget(preview: preview), + for (final WidgetPreview preview in previewList) + WidgetPreviewWidget(preview: preview), ], ), ), diff --git a/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/widget_preview_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/widget_preview_test.dart index ab539cb703..cbfaf3b439 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/widget_preview_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/widget_preview_test.dart @@ -59,6 +59,8 @@ void main() { 'can create a pubspec.yaml for the preview scaffold including root project assets', () { final FlutterManifest root = rootProject.manifest; + final FlutterManifest emptyPreviewManifest = + rootProject.widgetPreviewScaffoldProject.manifest; final FlutterManifest updated = command.buildPubspec( rootManifest: rootProject.manifest, widgetPreviewManifest: rootProject.widgetPreviewScaffoldProject.manifest, @@ -73,9 +75,28 @@ void main() { expect(updatedEntry, WidgetPreviewStartCommand.transformAssetsEntry(rootEntry)); } - expect(updated.fonts.length, root.fonts.length); - for (int i = 0; i < root.fonts.length; ++i) { - final Font rootFont = root.fonts[i]; + final int emptyPreviewFontCount = emptyPreviewManifest.fonts.length; + final int expectedFontCount = root.fonts.length + emptyPreviewFontCount; + expect(updated.fonts.length, expectedFontCount); + + // Verify that the updated preview scaffold pubspec includes fonts needed by + // the previewer. + for (int i = 0; i < emptyPreviewFontCount; ++i) { + final Font defaultPreviewerFont = emptyPreviewManifest.fonts[i]; + final Font updatedFont = updated.fonts[i]; + expect(updatedFont.familyName, defaultPreviewerFont.familyName); + expect(updatedFont.fontAssets.length, defaultPreviewerFont.fontAssets.length); + for (int j = 0; j < defaultPreviewerFont.fontAssets.length; ++j) { + final FontAsset rootFontAsset = defaultPreviewerFont.fontAssets[j]; + final FontAsset updatedFontAsset = updatedFont.fontAssets[j]; + expect(updatedFontAsset.descriptor, rootFontAsset.descriptor); + } + } + + // Verify fonts from the root project are included in the updated preview + // scaffold pubspec. + for (int i = emptyPreviewFontCount; i < expectedFontCount; ++i) { + final Font rootFont = root.fonts[i - emptyPreviewFontCount]; final Font updatedFont = updated.fonts[i]; expect(updatedFont.familyName, rootFont.familyName); expect(updatedFont.fontAssets.length, rootFont.fontAssets.length); diff --git a/packages/flutter_tools/test/commands.shard/permeable/widget_preview_test.dart b/packages/flutter_tools/test/commands.shard/permeable/widget_preview_test.dart index 61e57705a8..d5e4cc81d8 100644 --- a/packages/flutter_tools/test/commands.shard/permeable/widget_preview_test.dart +++ b/packages/flutter_tools/test/commands.shard/permeable/widget_preview_test.dart @@ -195,7 +195,7 @@ Widget preview() => Text('Foo');'''; const String expectedGeneratedFileContents = ''' // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'widget_preview.dart' as _i1;import 'package:flutter_project/foo.dart' as _i2;List<_i1.WidgetPreview> previews() => [_i1.WidgetPreview(name: 'preview', child: _i2.preview(), )];'''; +import 'widget_preview.dart' as _i1;import 'package:flutter_project/foo.dart' as _i2;List<_i1.WidgetPreview> previews() => [_i1.WidgetPreview(name: 'preview', builder: () => _i2.preview(), )];'''; testUsingContext( 'start finds existing previews and injects them into ${PreviewCodeGenerator.generatedPreviewFilePath}', diff --git a/packages/flutter_tools/test/general.shard/widget_preview/preview_code_generator_test.dart b/packages/flutter_tools/test/general.shard/widget_preview/preview_code_generator_test.dart index 7b2eb42ce6..758fd6a767 100644 --- a/packages/flutter_tools/test/general.shard/widget_preview/preview_code_generator_test.dart +++ b/packages/flutter_tools/test/general.shard/widget_preview/preview_code_generator_test.dart @@ -73,7 +73,7 @@ void main() { // The generated file is unfortunately unformatted. const String expectedGeneratedPreviewFileContents = ''' // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'widget_preview.dart' as _i1;import 'foo.dart' as _i2;import 'src/bar.dart' as _i3;import 'wrapper.dart' as _i4;import 'package:flutter/widgets.dart' as _i5;List<_i1.WidgetPreview> previews() => [_i1.WidgetPreview(child: _i2.preview()), _i1.WidgetPreview(child: _i3.barPreview1()), _i1.WidgetPreview(child: _i3.barPreview2()), _i1.WidgetPreview(name: Foo, height: 456.0, width: 123.0, textScaleFactor: 50.0, child: _i4.wrapper(_i5.Builder(builder: _i3.barPreview3())), ), ];'''; +import 'widget_preview.dart' as _i1;import 'foo.dart' as _i2;import 'src/bar.dart' as _i3;import 'wrapper.dart' as _i4;import 'package:flutter/widgets.dart' as _i5;List<_i1.WidgetPreview> previews() => [_i1.WidgetPreview(builder: () => _i2.preview()), _i1.WidgetPreview(builder: () => _i3.barPreview1()), _i1.WidgetPreview(builder: () => _i3.barPreview2()), _i1.WidgetPreview(name: Foo, height: 456.0, width: 123.0, textScaleFactor: 50.0, builder: () => _i4.wrapper(_i5.Builder(builder: _i3.barPreview3())), ), ];'''; expect(generatedPreviewFile.readAsStringSync(), expectedGeneratedPreviewFileContents); // Regenerate the generated file with no previews.