[ Widget Previews ] Add widget_preview_scaffold.shard
to test the widget_preview_scaffold
template contents (#166358)
Adds a new `widget_preview_scaffold.shard` directory which contains a hydrated `widget_preview_scaffold` template. This will allow for us to write widget tests against the widgets defined in the templates. This PR doesn't add any widget tests and is only adding the ability to run these tests in follow up changes. Fixes https://github.com/flutter/flutter/issues/166416
This commit is contained in:
parent
a400e79ce6
commit
d6c0d6fee7
23
.ci.yaml
23
.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
|
||||
|
@ -236,10 +236,17 @@ Future<void> _runIntegrationToolTests() async {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _runWidgetPreviewScaffoldToolTests() async {
|
||||
await runFlutterTest(
|
||||
path.join(_toolsPath, 'test', 'widget_preview_scaffold.shard', 'widget_preview_scaffold'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _runToolTests() async {
|
||||
await selectSubshard(<String, ShardRunner>{
|
||||
'general': _runGeneralToolTests,
|
||||
'commands': _runCommandsToolTests,
|
||||
'widget_preview_scaffold': _runWidgetPreviewScaffoldToolTests,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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<Set<DevelopmentArtifact>> get requiredArtifacts async => const <DevelopmentArtifact>{
|
||||
@ -189,30 +196,36 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
|
||||
);
|
||||
|
||||
late final PreviewCodeGenerator _previewCodeGenerator;
|
||||
late final PreviewManifest _previewManifest;
|
||||
|
||||
/// The currently running instance of the widget preview scaffold.
|
||||
AppInstance? _widgetPreviewApp;
|
||||
|
||||
@override
|
||||
Future<FlutterCommandResult> runCommand() async {
|
||||
final Directory widgetPreviewScaffold = rootProject.widgetPreviewScaffold;
|
||||
_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<FlutterCommandResult> runCommand() async {
|
||||
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(
|
||||
<String>['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
|
||||
|
@ -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.
|
||||
|
@ -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<WidgetPreview> previews() => <WidgetPreview>[];
|
||||
|
@ -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
|
||||
|
2
packages/flutter_tools/test/widget_preview_scaffold.shard/.gitignore
vendored
Normal file
2
packages/flutter_tools/test/widget_preview_scaffold.shard/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
# The generated web assets aren't needed for widget_preview_scaffold widget tests
|
||||
/widget_preview_scaffold/web/
|
@ -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.
|
@ -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<String> args = <String>[
|
||||
'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.');
|
||||
}
|
||||
}
|
45
packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/.gitignore
vendored
Normal file
45
packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/.gitignore
vendored
Normal file
@ -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
|
@ -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.
|
@ -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
|
@ -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<void> main() async {
|
||||
await mainImpl();
|
||||
}
|
@ -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: <Widget>[
|
||||
_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();
|
||||
}
|
||||
}
|
@ -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<WidgetPreview> previews() => <WidgetPreview>[];
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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: <Widget>[
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: <TextSpan>[
|
||||
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<TextSpan> _formatFrames(List<Frame> 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<TextSpan>((frame) {
|
||||
if (frame is UnparsedFrame) return TextSpan(text: '$frame\n');
|
||||
return TextSpan(
|
||||
children: <TextSpan>[
|
||||
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<WidgetPreviewWidget> createState() => _WidgetPreviewWidgetState();
|
||||
}
|
||||
|
||||
class _WidgetPreviewWidgetState extends State<WidgetPreviewWidget> {
|
||||
final transformationController = TransformationController();
|
||||
final deviceOrientation = ValueNotifier<Orientation>(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<ByteData> 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<ImmutableBuffer> 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<void> mainImpl() async {
|
||||
runApp(_WidgetPreviewScaffold());
|
||||
}
|
||||
|
||||
class _WidgetPreviewScaffold extends StatelessWidget {
|
||||
const _WidgetPreviewScaffold();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<WidgetPreview> previewList = previews();
|
||||
Widget previewView;
|
||||
if (previewList.isEmpty) {
|
||||
previewView = Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
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: <Widget>[
|
||||
for (final WidgetPreview preview in previewList)
|
||||
WidgetPreviewWidget(preview: preview),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Material(
|
||||
color: Colors.transparent,
|
||||
child: DefaultAssetBundle(
|
||||
bundle: PreviewAssetBundle(),
|
||||
child: previewView,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
{"version":"0.0.1","sdk-version":"3.8.0 (build 3.8.0-227.0.dev)","pubspec-hash":"49d61ee8be4dbdce668cba26b60bb4b2"}
|
@ -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
|
@ -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.',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
@ -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<String> _ignoreDiffSet = <String>{
|
||||
// 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;
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user