// 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:args/args.dart'; import 'package:meta/meta.dart'; import '../base/common.dart'; import '../base/deferred_component.dart'; import '../base/file_system.dart'; import '../base/platform.dart'; import '../cache.dart'; import '../convert.dart'; import '../dart/pub.dart'; import '../flutter_manifest.dart'; import '../globals.dart' as globals; import '../project.dart'; import '../runner/flutter_command.dart'; import 'create_base.dart'; class WidgetPreviewCommand extends FlutterCommand { WidgetPreviewCommand() { addSubcommand(WidgetPreviewStartCommand()); addSubcommand(WidgetPreviewCleanCommand()); } @override String get description => 'Manage the widget preview environment.'; @override String get name => 'widget-preview'; @override String get category => FlutterCommandCategory.tools; // TODO(bkonyi): show when --verbose is not provided when this feature is // ready to ship. @override bool get hidden => true; @override Future runCommand() async => FlutterCommandResult.fail(); } /// Common utilities for the 'start' and 'clean' commands. mixin WidgetPreviewSubCommandMixin on FlutterCommand { FlutterProject getRootProject() { final ArgResults results = argResults!; final Directory projectDir; if (results.rest case [final String directory]) { projectDir = globals.fs.directory(directory); if (!projectDir.existsSync()) { throwToolExit('Could not find ${projectDir.path}.'); } } else if (results.rest.length > 1) { throwToolExit('Only one directory should be provided.'); } else { projectDir = globals.fs.currentDirectory; } return validateFlutterProjectForPreview(projectDir); } FlutterProject validateFlutterProjectForPreview(Directory directory) { globals.logger.printTrace('Verifying that ${directory.path} is a Flutter project.'); final FlutterProject flutterProject = globals.projectFactory.fromDirectory(directory); if (!flutterProject.dartTool.existsSync()) { throwToolExit('${flutterProject.directory.path} is not a valid Flutter project.'); } return flutterProject; } } class WidgetPreviewStartCommand extends FlutterCommand with CreateBase, WidgetPreviewSubCommandMixin { WidgetPreviewStartCommand() { addPubOptions(); } @override String get description => 'Starts the widget preview environment.'; @override String get name => 'start'; @override Future runCommand() async { final FlutterProject rootProject = getRootProject(); final Directory widgetPreviewScaffold = rootProject.widgetPreviewScaffold; // Check to see if a preview scaffold has already been generated. If not, // generate one. if (!widgetPreviewScaffold.existsSync()) { globals.logger.printStatus( 'Creating widget preview scaffolding at: ${widgetPreviewScaffold.path}', ); await generateApp( ['widget_preview_scaffold'], widgetPreviewScaffold, createTemplateContext( organization: 'flutter', projectName: 'widget_preview_scaffold', titleCaseProjectName: 'Widget Preview Scaffold', flutterRoot: Cache.flutterRoot!, dartSdkVersionBounds: '^${globals.cache.dartSdkBuild}', linux: const LocalPlatform().isLinux, macos: const LocalPlatform().isMacOS, windows: const LocalPlatform().isWindows, ), overwrite: true, generateMetadata: false, ); await _populatePreviewPubspec(rootProject: rootProject); } return FlutterCommandResult.success(); } @visibleForTesting static const Map flutterGenPackageConfigEntry = { 'name': 'flutter_gen', 'rootUri': '../../flutter_gen', 'languageVersion': '2.12', }; /// Maps asset URIs to relative paths for the widget preview project to /// include. @visibleForTesting static Uri transformAssetUri(Uri uri) { // Assets provided by packages always start with 'packages' and do not // require their URIs to be updated. if (uri.path.startsWith('packages')) { return uri; } // Otherwise, the asset is contained within the root project and needs // to be referenced from the widget preview scaffold project's pubspec. return Uri(path: '../../${uri.path}'); } @visibleForTesting static AssetsEntry transformAssetsEntry(AssetsEntry asset) { return AssetsEntry( uri: transformAssetUri(asset.uri), flavors: asset.flavors, transformers: asset.transformers, ); } @visibleForTesting static FontAsset transformFontAsset(FontAsset asset) { return FontAsset(transformAssetUri(asset.assetUri), weight: asset.weight, style: asset.style); } @visibleForTesting static DeferredComponent transformDeferredComponent(DeferredComponent component) { return DeferredComponent( name: component.name, // TODO(bkonyi): verify these library paths are always package: paths from the parent project. libraries: component.libraries, assets: component.assets.map(transformAssetsEntry).toList(), ); } @visibleForTesting FlutterManifest buildPubspec({ required FlutterManifest rootManifest, required FlutterManifest widgetPreviewManifest, }) { 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 shaders = rootManifest.shaders.map(transformAssetUri).toList(); final List models = rootManifest.models.map(transformAssetUri).toList(); final List? deferredComponents = rootManifest.deferredComponents?.map(transformDeferredComponent).toList(); return widgetPreviewManifest.copyWith( logger: globals.logger, assets: assets, fonts: fonts, shaders: shaders, models: models, deferredComponents: deferredComponents, ); } Future _populatePreviewPubspec({required FlutterProject rootProject}) async { final FlutterProject widgetPreviewScaffoldProject = rootProject.widgetPreviewScaffoldProject; // Overwrite the pubspec for the preview scaffold project to include assets // from the root project. widgetPreviewScaffoldProject.replacePubspec( buildPubspec( rootManifest: rootProject.manifest, widgetPreviewManifest: widgetPreviewScaffoldProject.manifest, ), ); // Adds a path dependency on the parent project so previews can be // imported directly into the preview scaffold. const String pubAdd = 'add'; await pub.interactively( [ pubAdd, '--directory', widgetPreviewScaffoldProject.directory.path, '${rootProject.manifest.appName}:{"path":${rootProject.directory.path}}', ], context: PubContext.pubAdd, command: pubAdd, touchesPackageConfig: true, ); // Generate package_config.json. await pub.get( context: PubContext.create, project: widgetPreviewScaffoldProject, offline: offline, outputMode: PubOutputMode.summaryOnly, ); maybeAddFlutterGenToPackageConfig(rootProject: rootProject); } /// Manually adds an entry for package:flutter_gen to the preview scaffold's /// package_config.json if the target project makes use of localization. /// /// The Flutter Tool does this when running a Flutter project with /// localization instead of modifying the user's pubspec.yaml to depend on it /// as a path dependency. Unfortunately, the preview scaffold still needs to /// add it directly to its package_config.json as the generated package name /// isn't actually flutter_gen, which pub doesn't really like, and using the /// actual package name will break applications which import /// package:flutter_gen. @visibleForTesting void maybeAddFlutterGenToPackageConfig({required FlutterProject rootProject}) { // TODO(matanlurey): Remove this once flutter_gen is removed. // // This is actually incorrect logic; the presence of a `generate: true` // does *NOT* mean that we need to add `flutter_gen` to the package config, // and never did, but the name of the manifest field was labeled and // described incorrectly. // // Tracking removal: https://github.com/flutter/flutter/issues/102983. if (!rootProject.manifest.generateLocalizations) { return; } final FlutterProject widgetPreviewScaffoldProject = rootProject.widgetPreviewScaffoldProject; final File packageConfig = widgetPreviewScaffoldProject.packageConfig; final String previewPackageConfigPath = packageConfig.path; if (!packageConfig.existsSync()) { throw StateError( "Could not find preview project's package_config.json at " '$previewPackageConfigPath', ); } final Map packageConfigJson = json.decode(packageConfig.readAsStringSync()) as Map; (packageConfigJson['packages'] as List?)!.cast>().add( flutterGenPackageConfigEntry, ); packageConfig.writeAsStringSync(json.encode(packageConfigJson)); globals.logger.printStatus('Added flutter_gen dependency to $previewPackageConfigPath'); } } class WidgetPreviewCleanCommand extends FlutterCommand with WidgetPreviewSubCommandMixin { @override String get description => 'Cleans up widget preview state.'; @override String get name => 'clean'; @override Future runCommand() async { final Directory widgetPreviewScaffold = getRootProject().widgetPreviewScaffold; if (widgetPreviewScaffold.existsSync()) { final String scaffoldPath = widgetPreviewScaffold.path; globals.logger.printStatus('Deleting widget preview scaffold at $scaffoldPath.'); widgetPreviewScaffold.deleteSync(recursive: true); } else { globals.logger.printStatus('Nothing to clean up.'); } return FlutterCommandResult.success(); } }