diff --git a/.ci.yaml b/.ci.yaml index 76cf2de657..56d8d68132 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -1589,6 +1589,29 @@ targets: - engine/** - DEPS + - name: Linux tool_tests_widget_preview_scaffold + recipe: flutter/flutter_drone + timeout: 60 + bringup: true + properties: + add_recipes_cq: "true" + dependencies: >- + [ + {"dependency": "android_sdk", "version": "version:35v1"}, + {"dependency": "open_jdk", "version": "version:21"} + ] + shard: tool_tests + subshard: widget_preview_scaffold + tags: > + ["framework", "hostonly", "shard", "linux"] + runIf: + - dev/** + - packages/flutter_tools/** + - bin/** + - .ci.yaml + - engine/** + - DEPS + - name: Linux_android_emu android_engine_vulkan_tests recipe: flutter/flutter_drone timeout: 60 diff --git a/dev/bots/test.dart b/dev/bots/test.dart index fb87f58865..18b76b7840 100644 --- a/dev/bots/test.dart +++ b/dev/bots/test.dart @@ -236,10 +236,17 @@ Future _runIntegrationToolTests() async { ); } +Future _runWidgetPreviewScaffoldToolTests() async { + await runFlutterTest( + path.join(_toolsPath, 'test', 'widget_preview_scaffold.shard', 'widget_preview_scaffold'), + ); +} + Future _runToolTests() async { await selectSubshard({ 'general': _runGeneralToolTests, 'commands': _runCommandsToolTests, + 'widget_preview_scaffold': _runWidgetPreviewScaffoldToolTests, }); } diff --git a/packages/flutter_tools/lib/src/commands/widget_preview.dart b/packages/flutter_tools/lib/src/commands/widget_preview.dart index 74dfc2b90e..abc7e66bbd 100644 --- a/packages/flutter_tools/lib/src/commands/widget_preview.dart +++ b/packages/flutter_tools/lib/src/commands/widget_preview.dart @@ -137,6 +137,12 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C kHeadlessWeb, help: 'Launches Chrome in headless mode for testing.', hide: !verboseHelp, + ) + ..addOption( + kWidgetPreviewScaffoldOutputDir, + help: + 'Generated the widget preview environment scaffolding at a given location ' + 'for testing purposes.', ); } @@ -144,6 +150,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C static const String kLaunchPreviewer = 'launch-previewer'; static const String kUseFlutterDesktop = 'desktop'; static const String kHeadlessWeb = 'headless-web'; + static const String kWidgetPreviewScaffoldOutputDir = 'scaffold-output-dir'; @override Future> get requiredArtifacts async => const { @@ -189,30 +196,36 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C ); late final PreviewCodeGenerator _previewCodeGenerator; - late final PreviewManifest _previewManifest; + late final PreviewManifest _previewManifest = PreviewManifest( + logger: logger, + rootProject: rootProject, + fs: fs, + cache: cache, + ); /// The currently running instance of the widget preview scaffold. AppInstance? _widgetPreviewApp; @override Future runCommand() async { - final Directory widgetPreviewScaffold = rootProject.widgetPreviewScaffold; - _previewManifest = PreviewManifest( - logger: logger, - rootProject: rootProject, - fs: fs, - cache: cache, - ); + final String? customPreviewScaffoldOutput = stringArg(kWidgetPreviewScaffoldOutputDir); + final Directory widgetPreviewScaffold = + customPreviewScaffoldOutput != null + ? fs.directory(customPreviewScaffoldOutput) + : rootProject.widgetPreviewScaffold; // Check to see if a preview scaffold has already been generated. If not, // generate one. - final bool generateScaffoldProject = _previewManifest.shouldGenerateProject(); + final bool generateScaffoldProject = + customPreviewScaffoldOutput != null || _previewManifest.shouldGenerateProject(); // TODO(bkonyi): can this be moved? widgetPreviewScaffold.createSync(); if (generateScaffoldProject) { // WARNING: this log message is used by test/integration.shard/widget_preview_test.dart - logger.printStatus('Creating widget preview scaffolding at: ${widgetPreviewScaffold.path}'); + logger.printStatus( + 'Creating widget preview scaffolding at: ${widgetPreviewScaffold.absolute.path}', + ); await generateApp( ['app', kWidgetPreviewScaffoldName], widgetPreviewScaffold, @@ -230,6 +243,9 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C overwrite: true, generateMetadata: false, ); + if (customPreviewScaffoldOutput != null) { + return FlutterCommandResult.success(); + } _previewManifest.generate(); // WARNING: this access of widgetPreviewScaffoldProject needs to happen diff --git a/packages/flutter_tools/templates/widget_preview_scaffold/README.md.tmpl b/packages/flutter_tools/templates/widget_preview_scaffold/README.md.tmpl index 3648fd7744..d09c93b423 100644 --- a/packages/flutter_tools/templates/widget_preview_scaffold/README.md.tmpl +++ b/packages/flutter_tools/templates/widget_preview_scaffold/README.md.tmpl @@ -1,4 +1,4 @@ -# {{titleCaseProjectName}} +# Widget Preview Scaffold This project is generated by `flutter widget-preview` and is used to host Widgets to be previewed in the widget previewer. diff --git a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/generated_preview.dart.tmpl b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/generated_preview.dart.tmpl index 7817e12b33..2b8255c1b7 100644 --- a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/generated_preview.dart.tmpl +++ b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/generated_preview.dart.tmpl @@ -2,7 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/widgets.dart'; import 'widget_preview.dart'; List previews() => []; 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 9010004947..e9ca0333c6 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 @@ -50,4 +50,4 @@ class WidgetPreview { /// If not provided, the default text scaling factor provided by [MediaQuery] /// will be used. final double? textScaleFactor; -} \ No newline at end of file +} diff --git a/packages/flutter_tools/templates/widget_preview_scaffold/pubspec.yaml.tmpl b/packages/flutter_tools/templates/widget_preview_scaffold/pubspec.yaml.tmpl index fb82124b13..ca4a8ecee5 100644 --- a/packages/flutter_tools/templates/widget_preview_scaffold/pubspec.yaml.tmpl +++ b/packages/flutter_tools/templates/widget_preview_scaffold/pubspec.yaml.tmpl @@ -1,4 +1,4 @@ -name: {{projectName}} +name: widget_preview_scaffold description: Scaffolding for Flutter Widget Previews publish_to: 'none' version: 0.0.1 @@ -11,6 +11,9 @@ dependencies: sdk: flutter flutter_test: sdk: flutter + # These will be replaced with proper constraints after the template is hydrated. + flutter_lints: any + stack_trace: any flutter: uses-material-design: true diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/.gitignore b/packages/flutter_tools/test/widget_preview_scaffold.shard/.gitignore new file mode 100644 index 0000000000..6375cfc1e5 --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/.gitignore @@ -0,0 +1,2 @@ +# The generated web assets aren't needed for widget_preview_scaffold widget tests +/widget_preview_scaffold/web/ \ No newline at end of file diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/README.md b/packages/flutter_tools/test/widget_preview_scaffold.shard/README.md new file mode 100644 index 0000000000..62cc5c9666 --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/README.md @@ -0,0 +1,7 @@ +# Widget Preview Scaffold Testing + +This directory contains a hydrated instance of the `widget_preview_scaffold` project template that's used for generating the environment used by `flutter widget-preview start` to host Widget Previews, as well as utilities to detect if the hydrated instance is outdated when compared to the template files. + +# Updating the Hydrated Template + +If any of the `widget_preview_scaffold` template files are updated, `widget_preview_scaffold/test/template_change_detection_smoke_test.dart` will fail to indicate that the hydrated scaffold needs to be regenerated. To do this, run `dart test/widget_preview_scaffold.shard/update_widget_preview_scaffold.dart` to regenerate the project. diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/update_widget_preview_scaffold.dart b/packages/flutter_tools/test/widget_preview_scaffold.shard/update_widget_preview_scaffold.dart new file mode 100644 index 0000000000..2cba040acf --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/update_widget_preview_scaffold.dart @@ -0,0 +1,36 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:path/path.dart' as path; // flutter_ignore: package_path_import + +import 'widget_preview_scaffold/test/widget_preview_scaffold_test_utils.dart'; + +/// Regenerates the widget_preview_scaffold if needed. +void main() { + if (WidgetPreviewScaffoldTestUtils.checkForTemplateUpdates( + widgetPreviewScaffoldProject: Directory( + Platform.script.resolve('widget_preview_scaffold/').path, + ), + widgetPreviewScaffoldTemplateDir: Directory( + Platform.script.resolve(path.join('..', '..', 'templates', 'widget_preview_scaffold')).path, + ), + )) { + stdout.writeln('Changes detected in the widget_preview_scaffold project templates.'); + stdout.writeln('Regenerating...'); + final List args = [ + 'widget-preview', + 'start', + '--scaffold-output-dir=${Platform.script.resolve('widget_preview_scaffold').path}', + ]; + stdout.writeln('Executing: flutter ${args.join(' ')}'); + final ProcessResult result = Process.runSync('flutter', args); + stdout.writeln(result.stdout); + stderr.writeln(result.stderr); + stdout.writeln('Regenerated widget_preview_scaffold.'); + } else { + stdout.writeln('No changes detected in the widget_preview_scaffold project templates.'); + } +} diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/.gitignore b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/.gitignore new file mode 100644 index 0000000000..79c113f9b5 --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/README.md b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/README.md new file mode 100644 index 0000000000..d09c93b423 --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/README.md @@ -0,0 +1,4 @@ +# Widget Preview Scaffold + +This project is generated by `flutter widget-preview` and is used to host Widgets +to be previewed in the widget previewer. diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/analysis_options.yaml b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/analysis_options.yaml new file mode 100644 index 0000000000..0d2902135c --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/main.dart b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/main.dart new file mode 100644 index 0000000000..0dd047b6ad --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/main.dart @@ -0,0 +1,9 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'src/widget_preview_rendering.dart'; + +Future main() async { + await mainImpl(); +} diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/controls.dart b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/controls.dart new file mode 100644 index 0000000000..1e0125645b --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/controls.dart @@ -0,0 +1,97 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +class _WidgetPreviewIconButton extends StatelessWidget { + const _WidgetPreviewIconButton({ + required this.tooltip, + required this.onPressed, + required this.icon, + }); + + final String tooltip; + final void Function()? onPressed; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: Ink( + decoration: ShapeDecoration( + shape: const CircleBorder(), + color: onPressed != null ? Colors.lightBlue : Colors.grey, + ), + child: IconButton( + onPressed: onPressed, + icon: Icon(color: Colors.white, icon), + ), + ), + ); + } +} + +/// Provides controls to change the zoom level of a [WidgetPreview]. +class ZoomControls extends StatelessWidget { + /// Provides controls to change the zoom level of a [WidgetPreview]. + const ZoomControls({ + super.key, + required TransformationController transformationController, + required this.enabled, + }) : _transformationController = transformationController; + + final TransformationController _transformationController; + final bool enabled; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _WidgetPreviewIconButton( + tooltip: 'Zoom in', + onPressed: enabled ? _zoomIn : null, + icon: Icons.zoom_in, + ), + const SizedBox(width: 10), + _WidgetPreviewIconButton( + tooltip: 'Zoom out', + onPressed: enabled ? _zoomOut : null, + icon: Icons.zoom_out, + ), + const SizedBox(width: 10), + _WidgetPreviewIconButton( + tooltip: 'Reset zoom', + onPressed: enabled ? _reset : null, + icon: Icons.refresh, + ), + ], + ); + } + + void _zoomIn() { + _transformationController.value = Matrix4.copy( + _transformationController.value, + ).scaled(1.1); + } + + void _zoomOut() { + final Matrix4 updated = Matrix4.copy( + _transformationController.value, + ).scaled(0.9); + + // Don't allow for zooming out past the original size of the widget. + // Assumes scaling is evenly applied to the entire matrix. + if (updated.entry(0, 0) < 1.0) { + updated.setIdentity(); + } + + _transformationController.value = updated; + } + + void _reset() { + _transformationController.value = Matrix4.identity(); + } +} diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/generated_preview.dart b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/generated_preview.dart new file mode 100644 index 0000000000..2b8255c1b7 --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/generated_preview.dart @@ -0,0 +1,7 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'widget_preview.dart'; + +List previews() => []; diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/utils.dart b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/utils.dart new file mode 100644 index 0000000000..fc8d5ba4d4 --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/utils.dart @@ -0,0 +1,24 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +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. + const VerticalSpacer({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox(height: 10); + } +} diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview.dart b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview.dart new file mode 100644 index 0000000000..e9ca0333c6 --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview.dart @@ -0,0 +1,53 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +/// Wraps a [Widget], initializing various state and properties to allow for +/// previewing of the [Widget] in the widget previewer. +/// +/// WARNING: This interface is not stable and **will change**. +/// +/// See also: +/// +/// * [Preview], an annotation class used to mark functions returning widget +/// previews. +// TODO(bkonyi): link to actual documentation when available. +class WidgetPreview { + /// Wraps [builder] in a [WidgetPreview] instance that applies some set of + /// properties. + const WidgetPreview({ + required this.builder, + this.name, + this.width, + this.height, + this.textScaleFactor, + }); + + /// A description to be displayed alongside the preview. + /// + /// If not provided, no name will be associated with the preview. + final String? name; + + /// A callback to build the [Widget] to be rendered in the preview. + final Widget Function() builder; + + /// 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 [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 [Widget] returned by [builder]. + /// + /// If not provided, the default text scaling factor provided by [MediaQuery] + /// will be used. + final double? textScaleFactor; +} diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview_rendering.dart b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview_rendering.dart new file mode 100644 index 0000000000..8687b72756 --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview_rendering.dart @@ -0,0 +1,466 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// 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}); + + final WidgetPreview preview; + + @override + State createState() => _WidgetPreviewWidgetState(); +} + +class _WidgetPreviewWidgetState extends State { + final transformationController = TransformationController(); + final deviceOrientation = ValueNotifier(Orientation.portrait); + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final previewerConstraints = + WidgetPreviewerWindowConstraints.getRootConstraints(context); + + final maxSizeConstraints = previewerConstraints.copyWith( + minHeight: previewerConstraints.maxHeight / 2.0, + maxHeight: previewerConstraints.maxHeight / 2.0, + ); + + 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: preview, + ), + ); + + preview = MediaQuery(data: _buildMediaQueryOverride(), child: preview); + + preview = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.preview.name != null) ...[ + Text( + widget.preview.name!, + style: fixBlurryText( + TextStyle(fontSize: 16, fontWeight: FontWeight.w300), + ), + ), + const VerticalSpacer(), + ], + InteractiveViewerWrapper( + transformationController: transformationController, + child: preview, + ), + const VerticalSpacer(), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + ZoomControls( + transformationController: transformationController, + // If an unhandled exception was caught and we're displaying an error + // widget, these controls should be disabled. + enabled: !errorThrownDuringTreeConstruction, + ), + ], + ), + ], + ); + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Card( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0), + child: preview, + ), + ), + ); + } + + MediaQueryData _buildMediaQueryOverride() { + var mediaQueryData = MediaQuery.of(context); + + if (widget.preview.textScaleFactor != null) { + mediaQueryData = mediaQueryData.copyWith( + textScaler: TextScaler.linear(widget.preview.textScaleFactor!), + ); + } + + 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); + } + + return mediaQueryData; + } +} + +/// An [InheritedWidget] that propagates the current size of the +/// WidgetPreviewScaffold. +/// +/// This is needed when determining how to put constraints on previewed widgets +/// that would otherwise have infinite constraints. +class WidgetPreviewerWindowConstraints extends InheritedWidget { + const WidgetPreviewerWindowConstraints({ + super.key, + required super.child, + required this.constraints, + }); + + final BoxConstraints constraints; + + static BoxConstraints getRootConstraints(BuildContext context) { + final result = + context + .dependOnInheritedWidgetOfExactType< + WidgetPreviewerWindowConstraints + >(); + assert( + result != null, + 'No WidgetPreviewerWindowConstraints founds in context', + ); + return result!.constraints; + } + + @override + bool updateShouldNotify(WidgetPreviewerWindowConstraints oldWidget) { + return oldWidget.constraints != constraints; + } +} + +class InteractiveViewerWrapper extends StatelessWidget { + const InteractiveViewerWrapper({ + super.key, + required this.child, + required this.transformationController, + }); + + final Widget child; + final TransformationController transformationController; + + @override + Widget build(BuildContext context) { + return InteractiveViewer( + transformationController: transformationController, + scaleEnabled: false, + child: child, + ); + } +} + +// TODO(bkonyi): according to goderbauer@, this probably isn't the best approach to ensure we +// handle unconstrained widgets. This should be reworked. +/// Wrapper applying a custom render object to force constraints on +/// unconstrained widgets. +class _WidgetPreviewWrapper extends SingleChildRenderObjectWidget { + const _WidgetPreviewWrapper({ + super.child, + required this.previewerConstraints, + }); + + /// The size of the previewer render surface. + final BoxConstraints previewerConstraints; + + @override + RenderObject createRenderObject(BuildContext context) { + return _WidgetPreviewWrapperBox( + previewerConstraints: previewerConstraints, + child: null, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _WidgetPreviewWrapperBox renderObject, + ) { + renderObject.setPreviewerConstraints(previewerConstraints); + } +} + +/// Custom render box that forces constraints onto unconstrained widgets. +class _WidgetPreviewWrapperBox extends RenderShiftedBox { + _WidgetPreviewWrapperBox({ + required RenderBox? child, + required BoxConstraints previewerConstraints, + }) : _previewerConstraints = previewerConstraints, + super(child); + + BoxConstraints _constraintOverride = const BoxConstraints(); + BoxConstraints _previewerConstraints; + + void setPreviewerConstraints(BoxConstraints previewerConstraints) { + if (_previewerConstraints == previewerConstraints) { + return; + } + _previewerConstraints = previewerConstraints; + markNeedsLayout(); + } + + @override + void layout(Constraints constraints, {bool parentUsesSize = false}) { + if (child != null && constraints is BoxConstraints) { + double minInstrinsicHeight; + try { + minInstrinsicHeight = child!.getMinIntrinsicHeight( + constraints.maxWidth, + ); + } on Object { + minInstrinsicHeight = 0.0; + } + // Determine if the previewed widget is vertically constrained. If the + // widget has a minimum intrinsic height of zero given the widget's max + // width, it has an unconstrained height and will cause an overflow in + // 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(); + } + super.layout(constraints, parentUsesSize: parentUsesSize); + } + + @override + void performLayout() { + final child = this.child; + if (child == null) { + size = Size.zero; + return; + } + final updatedConstraints = _constraintOverride.enforce(constraints); + child.layout(updatedConstraints, parentUsesSize: true); + size = constraints.constrain(child.size); + } +} + +/// Custom [AssetBundle] used to map original asset paths from the parent +/// project to those in the preview project. +class PreviewAssetBundle extends PlatformAssetBundle { + // Assets shipped via package dependencies have paths that start with + // 'packages'. + static const String _kPackagesPrefix = 'packages'; + + @override + Future load(String key) { + // These assets are always present or are shipped via a package and aren't + // actually located in the parent project, meaning their paths did not need + // to be modified. + if (key == 'AssetManifest.bin' || + key == 'AssetManifest.json' || + key == 'FontManifest.json' || + key.startsWith(_kPackagesPrefix)) { + return super.load(key); + } + // Other assets are from the parent project. Map their keys to those found + // in the pubspec.yaml of the preview envirnment. + return super.load('../../$key'); + } + + @override + Future loadBuffer(String key) async { + if (kIsWeb) { + final ByteData bytes = await load(key); + return ImmutableBuffer.fromUint8List(Uint8List.sublistView(bytes)); + } + return await ImmutableBuffer.fromAsset( + key.startsWith(_kPackagesPrefix) ? key : '../../$key', + ); + } +} + +/// Main entrypoint for the widget previewer. +/// +/// We don't actually define this as `main` to avoid copying this file into +/// the preview scaffold project which prevents us from being able to use hot +/// restart to iterate on this file. +Future mainImpl() async { + runApp(_WidgetPreviewScaffold()); +} + +class _WidgetPreviewScaffold extends StatelessWidget { + const _WidgetPreviewScaffold(); + + @override + Widget build(BuildContext context) { + final List previewList = previews(); + Widget previewView; + if (previewList.isEmpty) { + previewView = Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Center( + // TODO: consider including details on how to get started + // with Widget Previews. + child: Text( + 'No previews available', + style: fixBlurryText(TextStyle(color: Colors.white)), + ), + ), + ], + ); + } else { + previewView = LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return WidgetPreviewerWindowConstraints( + constraints: constraints, + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final WidgetPreview preview in previewList) + WidgetPreviewWidget(preview: preview), + ], + ), + ), + ); + }, + ); + } + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Material( + color: Colors.transparent, + child: DefaultAssetBundle( + bundle: PreviewAssetBundle(), + child: previewView, + ), + ), + ); + } +} diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/preview_manifest.json b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/preview_manifest.json new file mode 100644 index 0000000000..e7f5dba511 --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/preview_manifest.json @@ -0,0 +1 @@ +{"version":"0.0.1","sdk-version":"3.8.0 (build 3.8.0-227.0.dev)","pubspec-hash":"49d61ee8be4dbdce668cba26b60bb4b2"} \ No newline at end of file diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/pubspec.yaml b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/pubspec.yaml new file mode 100644 index 0000000000..696c849d8c --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/pubspec.yaml @@ -0,0 +1,43 @@ +name: widget_preview_scaffold +description: Scaffolding for Flutter Widget Previews +publish_to: 'none' +version: 0.0.1 + +environment: + sdk: ^3.8.0-244.0.dev + +dependencies: + flutter: + sdk: flutter + flutter_test: + sdk: flutter + # These will be replaced with proper constraints after the template is hydrated. + flutter_lints: 5.0.0 + stack_trace: 1.12.1 + + async: 2.13.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + boolean_selector: 2.1.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + characters: 1.4.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + clock: 1.1.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + collection: 1.19.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + fake_async: 1.3.3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + leak_tracker: 10.0.9 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + leak_tracker_flutter_testing: 3.0.9 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + leak_tracker_testing: 3.0.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + lints: 5.1.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + matcher: 0.12.17 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + material_color_utilities: 0.11.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + meta: 1.16.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + path: 1.9.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + source_span: 1.10.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + stream_channel: 2.1.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + string_scanner: 1.4.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + term_glyph: 1.2.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + test_api: 0.7.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + vector_math: 2.1.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + vm_service: 15.0.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + +flutter: + uses-material-design: true + +# PUBSPEC CHECKSUM: 367e diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/template_change_detection_smoke_test.dart b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/template_change_detection_smoke_test.dart new file mode 100644 index 0000000000..cf5008c4f8 --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/template_change_detection_smoke_test.dart @@ -0,0 +1,31 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'widget_preview_scaffold_test_utils.dart'; + +void main() { + test('Widget Preview Scaffold template change detection', () { + if (WidgetPreviewScaffoldTestUtils.checkForTemplateUpdates( + widgetPreviewScaffoldProject: Directory( + Platform.script.resolve('.').path, + ), + widgetPreviewScaffoldTemplateDir: Directory( + '../../../templates/widget_preview_scaffold', + ), + )) { + stdout.writeln( + 'The widget_preview_scaffold contents do not match the widget_preview_scaffold ' + 'templates. Run "dart test/widget_preview_scaffold.shard/update_widget_preview_scaffold" ' + 'to update widget_preview_scaffold with the latest template contents.', + ); + fail( + 'widget_preview_scaffold.shard/widget_preview_scaffold is not up to date.', + ); + } + }); +} diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/widget_preview_scaffold_test_utils.dart b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/widget_preview_scaffold_test_utils.dart new file mode 100644 index 0000000000..e102dfe3e5 --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/widget_preview_scaffold_test_utils.dart @@ -0,0 +1,69 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +abstract class WidgetPreviewScaffoldTestUtils { + static final Set _ignoreDiffSet = { + // The pubspec can't be compared directly to the template since the SDK version is populated + // when the template is hydrated based on the current SDK version. + 'pubspec.yaml', + 'lib/src/generated_preview.dart', + }; + + /// Checks to see if the widget_preview_scaffold template files have been updated. + /// + /// Returns true if the widget_preview_scaffold project should be regenerated. + static bool checkForTemplateUpdates({ + required Directory widgetPreviewScaffoldProject, + required Directory widgetPreviewScaffoldTemplateDir, + }) { + bool updateDetected = false; + for (final FileSystemEntity entity in Directory( + widgetPreviewScaffoldTemplateDir.absolute.path, + ).listSync(recursive: true)) { + final String scaffoldPath = + entity.path + .replaceAll('.tmpl', '') + .split('widget_preview_scaffold/') + .last; + if (_ignoreDiffSet.contains(scaffoldPath)) { + continue; + } + final String resolvedScaffoldPath = + '${widgetPreviewScaffoldProject.absolute.path}$scaffoldPath'; + if (entity is Directory) { + if (!Directory(resolvedScaffoldPath).existsSync()) { + stdout.writeln( + 'ERROR: Failed to find directory at $resolvedScaffoldPath.', + ); + updateDetected = true; + } + } else if (entity is File) { + final File scaffoldFile = File(resolvedScaffoldPath); + if (!scaffoldFile.existsSync()) { + stdout.writeln( + 'ERROR: Failed to find file at $resolvedScaffoldPath.', + ); + updateDetected = true; + continue; + } + final String templateContent = entity.readAsStringSync(); + final String scaffoldContent = scaffoldFile.readAsStringSync(); + if (templateContent != scaffoldContent) { + stdout.writeln( + 'ERROR: The contents of $resolvedScaffoldPath do not match the contents of the template at ' + '${entity.path}.', + ); + updateDetected = true; + } + } else { + throw StateError( + 'Unexpected FileSystemEntity type: ${entity.runtimeType}', + ); + } + } + return updateDetected; + } +}