Allow users to create samples using flutter create. (#23584)
This adds flutter create --sample which allows users to execute a command which will create a working sample app from samples embedded in the API docs.
The command looks something like this:
flutter create --sample=chip.DeletableChipAttributes.onDeleted mysample
diff --git a/dev/snippets/config/skeletons/application.html b/dev/snippets/config/skeletons/application.html
index bbbed4f..479a8c0 100644
--- a/dev/snippets/config/skeletons/application.html
+++ b/dev/snippets/config/skeletons/application.html
@@ -20,7 +20,7 @@
</div>
<div class="snippet" id="longSnippet" hidden>
<div class="snippet-description">To create a sample project with this code snippet, run:<br/>
- <span class="snippet-create-command">flutter create --snippet={{id}} mysample</span>
+ <span class="snippet-create-command">flutter create --sample={{id}} mysample</span>
</div>
<div class="copyable-container">
<button class="copy-button-overlay copy-button" title="Copy to clipboard"
diff --git a/packages/flutter_tools/lib/src/commands/create.dart b/packages/flutter_tools/lib/src/commands/create.dart
index aa6bc7f..27d2d9f 100644
--- a/packages/flutter_tools/lib/src/commands/create.dart
+++ b/packages/flutter_tools/lib/src/commands/create.dart
@@ -3,6 +3,7 @@
// found in the LICENSE file.
import 'dart:async';
+import 'dart:convert';
import 'package:linter/src/rules/pub/package_names.dart' as package_names; // ignore: implementation_imports
import 'package:linter/src/utils.dart' as linter_utils; // ignore: implementation_imports
@@ -13,6 +14,7 @@
import '../android/gradle.dart' as gradle;
import '../base/common.dart';
import '../base/file_system.dart';
+import '../base/net.dart';
import '../base/os.dart';
import '../base/utils.dart';
import '../cache.dart';
@@ -85,6 +87,20 @@
defaultsTo: null,
);
argParser.addOption(
+ 'sample',
+ abbr: 's',
+ help: 'Specifies the Flutter code sample to use as the main.dart for an application. Implies '
+ '--template=app.',
+ defaultsTo: null,
+ valueHelp: 'the sample ID of the desired sample from the API documentation website (http://docs.flutter.io)'
+ );
+ argParser.addFlag(
+ 'overwrite',
+ negatable: true,
+ defaultsTo: false,
+ help: 'When performing operations, overwrite existing files.',
+ );
+ argParser.addOption(
'description',
defaultsTo: 'A new Flutter project.',
help: 'The description to use for your new Flutter project. This string ends up in the pubspec.yaml file.'
@@ -162,6 +178,19 @@
return null;
}
+ Future<String> _fetchSampleFromServer(String sampleId) async {
+ // Sanity check the sampleId
+ if (sampleId.contains(RegExp(r'[^-\w\.]'))) {
+ throwToolExit('Sample ID "$sampleId" contains invalid characters. Check the ID in the '
+ 'documentation and try again.');
+ }
+
+ final String host = FlutterVersion.instance.channel == 'stable'
+ ? 'docs.flutter.io'
+ : 'master-docs-flutter-io.firebaseapp.com';
+ return utf8.decode(await fetchUrl(Uri.https(host, 'snippets/$sampleId.dart')));
+ }
+
@override
Future<FlutterCommandResult> runCommand() async {
if (argResults.rest.isEmpty)
@@ -198,6 +227,17 @@
final Directory projectDir = fs.directory(argResults.rest.first);
final String projectDirPath = fs.path.normalize(projectDir.absolute.path);
+ String sampleCode;
+ if (argResults['sample'] != null) {
+ if (argResults['template'] != null &&
+ _stringToProjectType(argResults['template'] ?? 'app') != _ProjectType.app) {
+ throwToolExit('Cannot specify --sample with a project type other than '
+ '"${getEnumName(_ProjectType.app)}"');
+ }
+ // Fetch the sample from the server.
+ sampleCode = await _fetchSampleFromServer(argResults['sample']);
+ }
+
_ProjectType template;
_ProjectType detectedProjectType;
final bool metadataExists = projectDir.absolute.childFile('.metadata').existsSync();
@@ -241,7 +281,7 @@
}
}
- String error = _validateProjectDir(projectDirPath, flutterRoot: flutterRoot);
+ String error = _validateProjectDir(projectDirPath, flutterRoot: flutterRoot, overwrite: argResults['overwrite']);
if (error != null)
throwToolExit(error);
@@ -262,29 +302,38 @@
);
final String relativeDirPath = fs.path.relative(projectDirPath);
- if (!projectDir.existsSync()) {
+ if (!projectDir.existsSync() || projectDir.listSync().isEmpty) {
printStatus('Creating project $relativeDirPath...');
} else {
+ if (sampleCode != null && !argResults['overwrite']) {
+ throwToolExit('Will not overwrite existing project in $relativeDirPath: '
+ 'must specify --overwrite for samples to overwrite.');
+ }
printStatus('Recreating project $relativeDirPath...');
}
+
final Directory relativeDir = fs.directory(projectDirPath);
int generatedFileCount = 0;
switch (template) {
case _ProjectType.app:
- generatedFileCount += await _generateApp(relativeDir, templateContext);
+ generatedFileCount += await _generateApp(relativeDir, templateContext, overwrite: argResults['overwrite']);
break;
case _ProjectType.module:
- generatedFileCount += await _generateModule(relativeDir, templateContext);
+ generatedFileCount += await _generateModule(relativeDir, templateContext, overwrite: argResults['overwrite']);
break;
case _ProjectType.package:
- generatedFileCount += await _generatePackage(relativeDir, templateContext);
+ generatedFileCount += await _generatePackage(relativeDir, templateContext, overwrite: argResults['overwrite']);
break;
case _ProjectType.plugin:
- generatedFileCount += await _generatePlugin(relativeDir, templateContext);
+ generatedFileCount += await _generatePlugin(relativeDir, templateContext, overwrite: argResults['overwrite']);
break;
}
+ if (sampleCode != null) {
+ generatedFileCount += await _applySample(relativeDir, sampleCode);
+ }
printStatus('Wrote $generatedFileCount files.');
printStatus('\nAll done!');
+ final String application = sampleCode != null ? 'sample application' : 'application';
if (generatePackage) {
final String relativeMainPath = fs.path.normalize(fs.path.join(
relativeDirPath,
@@ -312,12 +361,12 @@
await doctor.summary();
printStatus('''
-In order to run your application, type:
+In order to run your $application, type:
\$ cd $relativeAppPath
\$ flutter run
-Your application code is in $relativeAppMain.
+Your $application code is in $relativeAppMain.
''');
if (generatePlugin) {
printStatus('''
@@ -339,20 +388,20 @@
're-validate your setup.');
printStatus("When complete, type 'flutter run' from the '$relativeAppPath' "
'directory in order to launch your app.');
- printStatus('Your application code is in $relativeAppMain');
+ printStatus('Your $application code is in $relativeAppMain');
}
}
return null;
}
- Future<int> _generateModule(Directory directory, Map<String, dynamic> templateContext) async {
+ Future<int> _generateModule(Directory directory, Map<String, dynamic> templateContext, {bool overwrite = false}) async {
int generatedCount = 0;
final String description = argResults.wasParsed('description')
? argResults['description']
: 'A new flutter module project.';
templateContext['description'] = description;
- generatedCount += _renderTemplate(fs.path.join('module', 'common'), directory, templateContext);
+ generatedCount += _renderTemplate(fs.path.join('module', 'common'), directory, templateContext, overwrite: overwrite);
if (argResults['pub']) {
await pubGet(
context: PubContext.create,
@@ -365,13 +414,13 @@
return generatedCount;
}
- Future<int> _generatePackage(Directory directory, Map<String, dynamic> templateContext) async {
+ Future<int> _generatePackage(Directory directory, Map<String, dynamic> templateContext, {bool overwrite = false}) async {
int generatedCount = 0;
final String description = argResults.wasParsed('description')
? argResults['description']
: 'A new Flutter package project.';
templateContext['description'] = description;
- generatedCount += _renderTemplate('package', directory, templateContext);
+ generatedCount += _renderTemplate('package', directory, templateContext, overwrite: overwrite);
if (argResults['pub']) {
await pubGet(
context: PubContext.createPackage,
@@ -382,13 +431,13 @@
return generatedCount;
}
- Future<int> _generatePlugin(Directory directory, Map<String, dynamic> templateContext) async {
+ Future<int> _generatePlugin(Directory directory, Map<String, dynamic> templateContext, {bool overwrite = false}) async {
int generatedCount = 0;
final String description = argResults.wasParsed('description')
? argResults['description']
: 'A new flutter plugin project.';
templateContext['description'] = description;
- generatedCount += _renderTemplate('plugin', directory, templateContext);
+ generatedCount += _renderTemplate('plugin', directory, templateContext, overwrite: overwrite);
if (argResults['pub']) {
await pubGet(
context: PubContext.createPlugin,
@@ -410,19 +459,19 @@
templateContext['pluginProjectName'] = projectName;
templateContext['androidPluginIdentifier'] = androidPluginIdentifier;
- generatedCount += await _generateApp(project.example.directory, templateContext);
+ generatedCount += await _generateApp(project.example.directory, templateContext, overwrite: overwrite);
return generatedCount;
}
- Future<int> _generateApp(Directory directory, Map<String, dynamic> templateContext) async {
+ Future<int> _generateApp(Directory directory, Map<String, dynamic> templateContext, {bool overwrite = false}) async {
int generatedCount = 0;
- generatedCount += _renderTemplate('app', directory, templateContext);
+ generatedCount += _renderTemplate('app', directory, templateContext, overwrite: overwrite);
final FlutterProject project = await FlutterProject.fromDirectory(directory);
generatedCount += _injectGradleWrapper(project);
if (argResults['with-driver-test']) {
final Directory testDirectory = directory.childDirectory('test_driver');
- generatedCount += _renderTemplate('driver', testDirectory, templateContext);
+ generatedCount += _renderTemplate('driver', testDirectory, templateContext, overwrite: overwrite);
}
if (argResults['pub']) {
@@ -435,6 +484,20 @@
return generatedCount;
}
+ // Takes an application template and replaces the main.dart with one from the
+ // documentation website in sampleCode. Returns the difference in the number
+ // of files after applying the sample, since it also deletes the application's
+ // test directory (since the template's test doesn't apply to the sample).
+ Future<int> _applySample(Directory directory, String sampleCode) async {
+ final File mainDartFile = directory.childDirectory('lib').childFile('main.dart');
+ await mainDartFile.create(recursive: true);
+ await mainDartFile.writeAsString(sampleCode);
+ final Directory testDir = directory.childDirectory('test');
+ final List<FileSystemEntity> files = testDir.listSync(recursive: true);
+ await testDir.delete(recursive: true);
+ return -files.length;
+ }
+
Map<String, dynamic> _templateContext({
String organization,
String projectName,
@@ -473,9 +536,9 @@
};
}
- int _renderTemplate(String templateName, Directory directory, Map<String, dynamic> context) {
+ int _renderTemplate(String templateName, Directory directory, Map<String, dynamic> context, {bool overwrite = false}) {
final Template template = Template.fromName(templateName);
- return template.render(directory, context, overwriteExisting: false);
+ return template.render(directory, context, overwriteExisting: overwrite);
}
int _injectGradleWrapper(FlutterProject project) {
@@ -557,12 +620,22 @@
/// Return null if the project directory is legal. Return a validation message
/// if we should disallow the directory name.
-String _validateProjectDir(String dirPath, { String flutterRoot }) {
+String _validateProjectDir(String dirPath, { String flutterRoot, bool overwrite = false }) {
if (fs.path.isWithin(flutterRoot, dirPath)) {
return 'Cannot create a project within the Flutter SDK. '
"Target directory '$dirPath' is within the Flutter SDK at '$flutterRoot'.";
}
+ // If the destination directory is actually a file, then we refuse to
+ // overwrite, on the theory that the user probably didn't expect it to exist.
+ if (fs.isFileSync(dirPath)) {
+ return "Invalid project name: '$dirPath' - refers to an existing file."
+ '${overwrite ? ' Refusing to overwrite a file with a directory.' : ''}';
+ }
+
+ if (overwrite)
+ return null;
+
final FileSystemEntityType type = fs.typeSync(dirPath);
if (type != FileSystemEntityType.notFound) {
diff --git a/packages/flutter_tools/lib/src/version.dart b/packages/flutter_tools/lib/src/version.dart
index e3ae88f..1262c57 100644
--- a/packages/flutter_tools/lib/src/version.dart
+++ b/packages/flutter_tools/lib/src/version.dart
@@ -19,7 +19,7 @@
class FlutterVersion {
@visibleForTesting
- FlutterVersion(this._clock) {
+ FlutterVersion([this._clock = const Clock()]) {
_channel = _runGit('git rev-parse --abbrev-ref --symbolic @{u}');
final String branch = _runGit('git rev-parse --abbrev-ref HEAD');
_branch = branch == 'HEAD' ? _channel : branch;
diff --git a/packages/flutter_tools/test/commands/create_test.dart b/packages/flutter_tools/test/commands/create_test.dart
index e72171d..c9016f6 100644
--- a/packages/flutter_tools/test/commands/create_test.dart
+++ b/packages/flutter_tools/test/commands/create_test.dart
@@ -8,6 +8,7 @@
import 'package:args/command_runner.dart';
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/io.dart';
+import 'package:flutter_tools/src/base/net.dart';
import 'package:flutter_tools/src/cache.dart';
import 'package:flutter_tools/src/commands/create.dart';
import 'package:flutter_tools/src/dart/sdk.dart';
@@ -56,6 +57,7 @@
'ios/Flutter/AppFrameworkInfo.plist',
'ios/Runner/AppDelegate.m',
'ios/Runner/GeneratedPluginRegistrant.h',
+ 'lib/main.dart',
],
);
return _runFlutterTest(projectDir);
@@ -719,18 +721,52 @@
);
});
- // Verify that we fail with an error code when the file exists.
- testUsingContext('fails when file exists', () async {
+ testUsingContext('fails when file exists where output directory should be', () async {
Cache.flutterRoot = '../..';
final CreateCommand command = CreateCommand();
final CommandRunner<void> runner = createTestCommandRunner(command);
- final File existingFile = fs.file('${projectDir.path.toString()}/bad');
+ final File existingFile = fs.file(fs.path.join(projectDir.path, 'bad'));
if (!existingFile.existsSync()) {
existingFile.createSync(recursive: true);
}
expect(
runner.run(<String>['create', existingFile.path]),
- throwsToolExit(message: 'file exists'),
+ throwsToolExit(message: 'existing file'),
+ );
+ });
+
+ testUsingContext('fails overwrite when file exists where output directory should be', () async {
+ Cache.flutterRoot = '../..';
+ final CreateCommand command = CreateCommand();
+ final CommandRunner<void> runner = createTestCommandRunner(command);
+ final File existingFile = fs.file(fs.path.join(projectDir.path, 'bad'));
+ if (!existingFile.existsSync()) {
+ existingFile.createSync(recursive: true);
+ }
+ expect(
+ runner.run(<String>['create', '--overwrite', existingFile.path]),
+ throwsToolExit(message: 'existing file'),
+ );
+ });
+
+ testUsingContext('overwrites existing directory when requested', () async {
+ Cache.flutterRoot = '../..';
+ final Directory existingDirectory = fs.directory(fs.path.join(projectDir.path, 'bad'));
+ if (!existingDirectory.existsSync()) {
+ existingDirectory.createSync(recursive: true);
+ }
+ final File existingFile = fs.file(fs.path.join(existingDirectory.path, 'lib', 'main.dart'));
+ existingFile.createSync(recursive: true);
+ await _createProject(
+ fs.directory(existingDirectory.path),
+ <String>['--overwrite'],
+ <String>[
+ 'android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java',
+ 'lib/main.dart',
+ 'ios/Flutter/AppFrameworkInfo.plist',
+ 'ios/Runner/AppDelegate.m',
+ 'ios/Runner/GeneratedPluginRegistrant.h',
+ ],
);
});
@@ -779,6 +815,24 @@
ProcessManager: () => loggingProcessManager,
},
);
+
+ testUsingContext('can create a sample-based project', () async {
+ await _createAndAnalyzeProject(
+ projectDir,
+ <String>['--no-pub', '--sample=foo.bar.Baz'],
+ <String>[
+ 'lib/main.dart',
+ 'flutter_project.iml',
+ 'android/app/src/main/AndroidManifest.xml',
+ 'ios/Flutter/AppFrameworkInfo.plist',
+ ],
+ unexpectedPaths: <String>['test'],
+ );
+ expect(projectDir.childDirectory('lib').childFile('main.dart').readAsStringSync(),
+ contains('void main() {}'));
+ }, timeout: allowForRemotePubInvocation, overrides: <Type, Generator>{
+ HttpClientFactory: () => () => MockHttpClient(200, result: 'void main() {}'),
+ });
}
Future<void> _createProject(
@@ -901,3 +955,62 @@
);
}
}
+
+class MockHttpClient implements HttpClient {
+ MockHttpClient(this.statusCode, {this.result});
+
+ final int statusCode;
+ final String result;
+
+ @override
+ Future<HttpClientRequest> getUrl(Uri url) async {
+ return MockHttpClientRequest(statusCode, result: result);
+ }
+
+ @override
+ dynamic noSuchMethod(Invocation invocation) {
+ throw 'io.HttpClient - $invocation';
+ }
+}
+
+class MockHttpClientRequest implements HttpClientRequest {
+ MockHttpClientRequest(this.statusCode, {this.result});
+
+ final int statusCode;
+ final String result;
+
+ @override
+ Future<HttpClientResponse> close() async {
+ return MockHttpClientResponse(statusCode, result: result);
+ }
+
+ @override
+ dynamic noSuchMethod(Invocation invocation) {
+ throw 'io.HttpClientRequest - $invocation';
+ }
+}
+
+class MockHttpClientResponse extends Stream<List<int>> implements HttpClientResponse {
+ MockHttpClientResponse(this.statusCode, {this.result});
+
+ @override
+ final int statusCode;
+
+ final String result;
+
+ @override
+ String get reasonPhrase => '<reason phrase>';
+
+ @override
+ StreamSubscription<List<int>> listen(void onData(List<int> event), {
+ Function onError, void onDone(), bool cancelOnError
+ }) {
+ return Stream<List<int>>.fromIterable(<List<int>>[result.codeUnits])
+ .listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError);
+ }
+
+ @override
+ dynamic noSuchMethod(Invocation invocation) {
+ throw 'io.HttpClientResponse - $invocation';
+ }
+}