diff --git a/.travis.yml b/.travis.yml index 7528cb4d07a23..71acda37d7b55 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,7 +22,8 @@ env: before_script: - ./dev/bots/travis_setup.sh script: - - (./bin/cache/dart-sdk/bin/dart ./dev/bots/test.dart && ./dev/bots/travis_upload.sh) + # TODO(tvolkert): Re-enable `&& ./dev/bots/travis_upload.sh` + - (./bin/cache/dart-sdk/bin/dart ./dev/bots/test.dart) cache: directories: - $HOME/.pub-cache diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f095763dcf7bb..5a566170d383f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ _See also: [Flutter's code of conduct](https://flutter.io/design-principles/#cod Things you will need -------------------- - * Linux or Mac OS X. (Windows is not yet supported.) + * Linux, Mac OS X, or Windows * git (used for source version control). * An IDE. We recommend [IntelliJ with the Flutter plugin](https://flutter.io/intellij-ide/). * An ssh client (used to authenticate with GitHub). @@ -92,6 +92,8 @@ assume you want to check a single package and the flutter repository has several Running the tests ----------------- +_The `flutter test` command is not available on Windows (See [#8516](https://github.com/flutter/flutter/issues/8516))._ + To automatically find all files named `_test.dart` inside a package's `test/` subdirectory, and run them inside the flutter shell as a test, use the `flutter test` command, e.g: * `cd examples/stocks` diff --git a/VERSION b/VERSION index 706f6bb3e6aff..817c9f8ec1324 100644 --- a/VERSION +++ b/VERSION @@ -6,4 +6,4 @@ # incompatible way, this version number might not change. Instead, the version # number for package:flutter will update to reflect that change. -0.0.11-dev +0.0.12-dev diff --git a/bin/internal/dart-sdk.version b/bin/internal/dart-sdk.version index a93ad5a024906..53cc1a6f9292c 100644 --- a/bin/internal/dart-sdk.version +++ b/bin/internal/dart-sdk.version @@ -1 +1 @@ -1.24.0-dev.6.7 +1.24.0 diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 9bdb9b82b64da..50614b9f664fe 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -fffe502d437ac7931f08c6cef3e3f71fbd36adaa +8a2d337446ca076048608c6b6a932110e7c0f9c9 diff --git a/bin/internal/gradle_wrapper.version b/bin/internal/gradle_wrapper.version new file mode 100644 index 0000000000000..97e3b0db18127 --- /dev/null +++ b/bin/internal/gradle_wrapper.version @@ -0,0 +1 @@ +0b5c1398d1d04ac245a310de98825cb7b3278e2a diff --git a/dev/benchmarks/microbenchmarks/lib/stocks/animation_bench.dart b/dev/benchmarks/microbenchmarks/lib/stocks/animation_bench.dart index 0e1043bd10512..8ebdad60f44b8 100644 --- a/dev/benchmarks/microbenchmarks/lib/stocks/animation_bench.dart +++ b/dev/benchmarks/microbenchmarks/lib/stocks/animation_bench.dart @@ -35,7 +35,7 @@ class BenchmarkingBinding extends LiveTestWidgetsFlutterBinding { Future main() async { assert(false); // don't run this in checked mode! Use --release. - stock_data.StockDataFetcher.actuallyFetchData = false; + stock_data.StockData.actuallyFetchData = false; final Stopwatch wallClockWatch = new Stopwatch(); final Stopwatch cpuWatch = new Stopwatch(); diff --git a/dev/benchmarks/microbenchmarks/lib/stocks/build_bench.dart b/dev/benchmarks/microbenchmarks/lib/stocks/build_bench.dart index dfde886a32ad5..0e648283141e5 100644 --- a/dev/benchmarks/microbenchmarks/lib/stocks/build_bench.dart +++ b/dev/benchmarks/microbenchmarks/lib/stocks/build_bench.dart @@ -17,7 +17,7 @@ const Duration kBenchmarkTime = const Duration(seconds: 15); Future main() async { assert(false); // don't run this in checked mode! Use --release. - stock_data.StockDataFetcher.actuallyFetchData = false; + stock_data.StockData.actuallyFetchData = false; // This allows us to call onBeginFrame even when the engine didn't request it, // and have it actually do something: diff --git a/dev/benchmarks/microbenchmarks/lib/stocks/layout_bench.dart b/dev/benchmarks/microbenchmarks/lib/stocks/layout_bench.dart index 933ff41429c5c..07eec5a9e8e40 100644 --- a/dev/benchmarks/microbenchmarks/lib/stocks/layout_bench.dart +++ b/dev/benchmarks/microbenchmarks/lib/stocks/layout_bench.dart @@ -16,7 +16,7 @@ import '../common.dart'; const Duration kBenchmarkTime = const Duration(seconds: 15); Future main() async { - stock_data.StockDataFetcher.actuallyFetchData = false; + stock_data.StockData.actuallyFetchData = false; // This allows us to call onBeginFrame even when the engine didn't request it, // and have it actually do something: diff --git a/dev/bots/docs.sh b/dev/bots/docs.sh index c9a7a415add6c..8a563d6851d25 100755 --- a/dev/bots/docs.sh +++ b/dev/bots/docs.sh @@ -14,7 +14,7 @@ bin/cache/dart-sdk/bin/pub global activate dartdoc 0.13.0+1 # a custom index.html, placing everything into dev/docs/doc. (cd dev/tools; ../../bin/cache/dart-sdk/bin/pub get) FLUTTER_ROOT=$PWD bin/cache/dart-sdk/bin/dart dev/tools/dartdoc.dart -FLUTTER_ROOT=$PWD bin/cache/dart-sdk/bin/dart dev/tools/javadoc.dart +FLUTTER_ROOT=$PWD bin/cache/dart-sdk/bin/dart dev/tools/java_and_objc_doc.dart # Ensure google webmaster tools can verify our site. cp dev/docs/google2ed1af765c529f57.html dev/docs/doc diff --git a/dev/bots/travis_setup.sh b/dev/bots/travis_setup.sh index 89a30db5b8713..d6d5da001360a 100755 --- a/dev/bots/travis_setup.sh +++ b/dev/bots/travis_setup.sh @@ -5,10 +5,11 @@ echo $KEY_FILE | base64 --decode > ../gcloud_key_file.json set -x -if [ -n "$TRAVIS" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ]; then - export CLOUDSDK_CORE_DISABLE_PROMPTS=1 - curl https://sdk.cloud.google.com | bash -fi +# TODO(tvolkert): Re-enable once this is able to successfully run on Travis +#if [ -n "$TRAVIS" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ]; then +# export CLOUDSDK_CORE_DISABLE_PROMPTS=1 +# curl https://sdk.cloud.google.com | bash +#fi # disable analytics on the bots and download Flutter dependencies ./bin/flutter config --no-analytics diff --git a/dev/bots/travis_upload.sh b/dev/bots/travis_upload.sh index 7201dfd426b45..595ff8c9b36df 100755 --- a/dev/bots/travis_upload.sh +++ b/dev/bots/travis_upload.sh @@ -6,7 +6,10 @@ export PATH="$PWD/bin:$PWD/bin/cache/dart-sdk/bin:$PATH" LCOV_FILE=./packages/flutter/coverage/lcov.info -if [ "$SHARD" = "coverage" ] && [ "$TRAVIS_PULL_REQUEST" = "false" ] && [ "$TRAVIS_BRANCH" = "master" ] && [ -f "$LCOV_FILE" ]; then +if [ "$SHARD" = "coverage" ] && \ + [ "$TRAVIS_PULL_REQUEST" = "false" ] && \ + [ "$TRAVIS_BRANCH" = "master" ] && \ + [ -f "$LCOV_FILE" ]; then GSUTIL=$HOME/google-cloud-sdk/bin/gsutil GCLOUD=$HOME/google-cloud-sdk/bin/gcloud diff --git a/dev/devicelab/bin/tasks/routing_test.dart b/dev/devicelab/bin/tasks/routing_test.dart new file mode 100644 index 0000000000000..f7d8f4c78b0a5 --- /dev/null +++ b/dev/devicelab/bin/tasks/routing_test.dart @@ -0,0 +1,85 @@ +// Copyright (c) 2016 The Chromium 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:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import 'package:flutter_devicelab/framework/adb.dart'; +import 'package:flutter_devicelab/framework/framework.dart'; +import 'package:flutter_devicelab/framework/utils.dart'; + +void main() { + task(() async { + final Device device = await devices.workingDevice; + await device.unlock(); + final Directory appDir = dir(path.join(flutterDirectory.path, 'dev/integration_tests/ui')); + section('TEST WHETHER `flutter drive --route` WORKS'); + await inDirectory(appDir, () async { + return await flutter( + 'drive', + options: ['--verbose', '-d', device.deviceId, '--route', '/smuggle-it', 'lib/route.dart'], + canFail: false, + ); + }); + section('TEST WHETHER `flutter run --route` WORKS'); + await inDirectory(appDir, () async { + final Completer ready = new Completer(); + bool ok; + print('run: starting...'); + final Process run = await startProcess( + path.join(flutterDirectory.path, 'bin', 'flutter'), + ['run', '--verbose', '--observatory-port=8888', '-d', device.deviceId, '--route', '/smuggle-it', 'lib/route.dart'], + ); + run.stdout + .transform(UTF8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + print('run:stdout: $line'); + if (line == '[ ] For a more detailed help message, press "h". To quit, press "q".') { + print('run: ready!'); + ready.complete(); + ok ??= true; + } + }); + run.stderr + .transform(UTF8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + stderr.writeln('run:stderr: $line'); + }); + run.exitCode.then((int exitCode) { ok = false; }); + await Future.any(>[ ready.future, run.exitCode ]); + if (!ok) + throw 'Failed to run test app.'; + print('drive: starting...'); + final Process drive = await startProcess( + path.join(flutterDirectory.path, 'bin', 'flutter'), + ['drive', '--use-existing-app', 'http://127.0.0.1:8888/', '--no-keep-app-running', 'lib/route.dart'], + ); + drive.stdout + .transform(UTF8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + print('drive:stdout: $line'); + }); + drive.stderr + .transform(UTF8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + stderr.writeln('drive:stderr: $line'); + }); + int result; + result = await drive.exitCode; + if (result != 0) + throw 'Failed to drive test app (exit code $result).'; + result = await run.exitCode; + if (result != 0) + throw 'Received unexpected exit code $result from run process.'; + }); + return new TaskResult.success(null); + }); +} diff --git a/dev/devicelab/lib/framework/adb.dart b/dev/devicelab/lib/framework/adb.dart index ee6281af7660e..7e29381084157 100644 --- a/dev/devicelab/lib/framework/adb.dart +++ b/dev/devicelab/lib/framework/adb.dart @@ -152,7 +152,7 @@ class AndroidDeviceDiscovery implements DeviceDiscovery { results.add(deviceID); } } else { - throw 'Failed to parse device from adb output: $line'; + throw 'Failed to parse device from adb output: "$line"'; } } @@ -259,6 +259,7 @@ class AndroidDevice implements Device { Future> getMemoryStats(String packageName) async { final String meminfo = await shellEval('dumpsys', ['meminfo', packageName]); final Match match = new RegExp(r'TOTAL\s+(\d+)').firstMatch(meminfo); + assert(match != null, 'could not parse dumpsys meminfo output'); return { 'total_kb': int.parse(match.group(1)), }; diff --git a/dev/devicelab/lib/framework/framework.dart b/dev/devicelab/lib/framework/framework.dart index 1d0cff0dc31ed..f86124eeeec7c 100644 --- a/dev/devicelab/lib/framework/framework.dart +++ b/dev/devicelab/lib/framework/framework.dart @@ -26,7 +26,7 @@ bool _isTaskRegistered = false; /// Registers a [task] to run, returns the result when it is complete. /// -/// Note, the task does not run immediately but waits for the request via the +/// The task does not run immediately but waits for the request via the /// VM service protocol to run it. /// /// It is ok for a [task] to perform many things. However, only one task can be diff --git a/dev/devicelab/lib/framework/utils.dart b/dev/devicelab/lib/framework/utils.dart index 5b0c77e9d8ae8..3d2defd6e79e6 100644 --- a/dev/devicelab/lib/framework/utils.dart +++ b/dev/devicelab/lib/framework/utils.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:math' as math; import 'package:args/args.dart'; import 'package:meta/meta.dart'; @@ -116,7 +117,12 @@ void mkdirs(Directory directory) { bool exists(FileSystemEntity entity) => entity.existsSync(); void section(String title) { - print('\n••• $title •••'); + title = '╡ ••• $title ••• ╞'; + final String line = '═' * math.max((80 - title.length) ~/ 2, 2); + String output = '$line$title$line'; + if (output.length == 79) + output += '═'; + print('\n\n$output\n'); } Future getDartVersion() async { @@ -167,7 +173,7 @@ Future startProcess( String workingDirectory, }) async { final String command = '$executable ${arguments?.join(" ") ?? ""}'; - print('Executing: $command'); + print('\nExecuting: $command'); environment ??= {}; environment['BOT'] = 'true'; final Process process = await _processManager.start( @@ -178,7 +184,8 @@ Future startProcess( final ProcessInfo processInfo = new ProcessInfo(command, process); _runningProcesses.add(processInfo); - process.exitCode.whenComplete(() { + process.exitCode.then((int exitCode) { + print('exitcode: $exitCode'); _runningProcesses.remove(processInfo); }); @@ -211,15 +218,22 @@ Future exec( }) async { final Process process = await startProcess(executable, arguments, environment: environment); + final Completer stdoutDone = new Completer(); + final Completer stderrDone = new Completer(); process.stdout .transform(UTF8.decoder) .transform(const LineSplitter()) - .listen(print); + .listen((String line) { + print('stdout: $line'); + }, onDone: () { stdoutDone.complete(); }); process.stderr .transform(UTF8.decoder) .transform(const LineSplitter()) - .listen(stderr.writeln); + .listen((String line) { + print('stderr: $line'); + }, onDone: () { stderrDone.complete(); }); + await Future.wait(>[stdoutDone.future, stderrDone.future]); final int exitCode = await process.exitCode; if (exitCode != 0 && !canFail) @@ -230,7 +244,7 @@ Future exec( /// Executes a command and returns its standard output as a String. /// -/// Standard error is redirected to the current process' standard error stream. +/// For logging purposes, the command's output is also printed out. Future eval( String executable, List arguments, { @@ -238,16 +252,31 @@ Future eval( bool canFail: false, }) async { final Process process = await startProcess(executable, arguments, environment: environment); - process.stderr.listen((List data) { - stderr.add(data); - }); - final String output = await UTF8.decodeStream(process.stdout); + + final StringBuffer output = new StringBuffer(); + final Completer stdoutDone = new Completer(); + final Completer stderrDone = new Completer(); + process.stdout + .transform(UTF8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + print('stdout: $line'); + output.writeln(line); + }, onDone: () { stdoutDone.complete(); }); + process.stderr + .transform(UTF8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + print('stderr: $line'); + }, onDone: () { stderrDone.complete(); }); + + await Future.wait(>[stdoutDone.future, stderrDone.future]); final int exitCode = await process.exitCode; if (exitCode != 0 && !canFail) fail('Executable failed with exit code $exitCode.'); - return output.trimRight(); + return output.toString().trimRight(); } Future flutter(String command, { diff --git a/dev/devicelab/lib/tasks/microbenchmarks.dart b/dev/devicelab/lib/tasks/microbenchmarks.dart index a3844145a3ff3..a7ba367374088 100644 --- a/dev/devicelab/lib/tasks/microbenchmarks.dart +++ b/dev/devicelab/lib/tasks/microbenchmarks.dart @@ -59,7 +59,7 @@ TaskFunction createMicrobenchmarkTask() { } Future _startFlutter({ - String command = 'run', + String command: 'run', List options: const [], bool canFail: false, Map environment, diff --git a/dev/devicelab/manifest.yaml b/dev/devicelab/manifest.yaml index 5eb0fd94a4fae..f63a5e481a0c1 100644 --- a/dev/devicelab/manifest.yaml +++ b/dev/devicelab/manifest.yaml @@ -119,13 +119,18 @@ tasks: Builds sample catalog markdown pages and Android screenshots stage: devicelab required_agent_capabilities: ["has-android-device"] - flaky: true complex_layout_semantics_perf: description: > Measures duration of building the initial semantics tree. stage: devicelab required_agent_capabilities: ["linux/android"] + + routing_test: + description: > + Verifies that `flutter drive --route` still works. No performance numbers. + stage: devicelab + required_agent_capabilities: ["linux/android"] flaky: true # iOS on-device tests @@ -277,7 +282,6 @@ tasks: with semantics enabled. stage: devicelab required_agent_capabilities: ["linux/android"] - flaky: true flutter_gallery__memory_nav: description: > diff --git a/dev/devicelab/test/manifest_test.dart b/dev/devicelab/test/manifest_test.dart index 12185569d0022..6fff4c04f62e1 100644 --- a/dev/devicelab/test/manifest_test.dart +++ b/dev/devicelab/test/manifest_test.dart @@ -2,6 +2,8 @@ // 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:test/test.dart'; import 'package:flutter_devicelab/framework/manifest.dart'; @@ -16,6 +18,12 @@ void main() { expect(task.description, 'Measures the startup time of the Flutter Gallery app on Android.\n'); expect(task.stage, 'devicelab'); expect(task.requiredAgentCapabilities, ['linux/android']); + + for (ManifestTask task in manifest.tasks) { + final File taskFile = new File('bin/tasks/${task.name}.dart'); + expect(taskFile.existsSync(), true, + reason: 'File ${taskFile.path} corresponding to manifest task "${task.name}" not found'); + } }); }); diff --git a/dev/docs/platform_integration/lib/ios.dart b/dev/docs/platform_integration/lib/ios.dart new file mode 100644 index 0000000000000..86c2012df25ae --- /dev/null +++ b/dev/docs/platform_integration/lib/ios.dart @@ -0,0 +1,2 @@ +/// [Flutter platform integration APIs for iOS.](https://docs.flutter.io/objcdoc/) +library iOS; diff --git a/dev/integration_tests/ui/README.md b/dev/integration_tests/ui/README.md index ff91ce7c10942..364f593002cf2 100644 --- a/dev/integration_tests/ui/README.md +++ b/dev/integration_tests/ui/README.md @@ -1,7 +1,14 @@ # Flutter UI integration tests -This project contains a collection of non-plugin-dependent UI integration tests. +This project contains a collection of non-plugin-dependent UI +integration tests. The device code is in the `lib/` directory, the +driver code is in the `test_driver/` directory. They work together. +Normally they are run via the devicelab. ## keyboard\_resize Verifies that showing and hiding the keyboard resizes the content. + +## routing + +Verifies that `flutter drive --route` works correctly. diff --git a/dev/integration_tests/ui/lib/main.dart b/dev/integration_tests/ui/lib/main.dart index 1e74ba0be1cf7..66a92aad5ff8c 100644 --- a/dev/integration_tests/ui/lib/main.dart +++ b/dev/integration_tests/ui/lib/main.dart @@ -4,4 +4,4 @@ import 'package:flutter/widgets.dart'; -void main() => runApp(const Center(child: const Text('flutter run -t xxx.dart'))); +void main() => runApp(const Center(child: const Text('flutter drive lib/xxx.dart'))); diff --git a/dev/integration_tests/ui/lib/route.dart b/dev/integration_tests/ui/lib/route.dart new file mode 100644 index 0000000000000..2f8b507c580de --- /dev/null +++ b/dev/integration_tests/ui/lib/route.dart @@ -0,0 +1,15 @@ +// Copyright 2017 The Chromium 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:ui' as ui; + +import 'package:flutter_driver/driver_extension.dart'; + +// To use this test: "flutter drive --route '/smuggle-it' lib/route.dart" + +void main() { + enableFlutterDriverExtension(handler: (String message) async { + return ui.window.defaultRouteName; + }); +} diff --git a/dev/integration_tests/ui/test_driver/keyboard_resize_test.dart b/dev/integration_tests/ui/test_driver/keyboard_resize_test.dart index 5d624c60af8f1..3efb7fc930bd1 100644 --- a/dev/integration_tests/ui/test_driver/keyboard_resize_test.dart +++ b/dev/integration_tests/ui/test_driver/keyboard_resize_test.dart @@ -13,8 +13,7 @@ void main() { }); tearDownAll(() async { - if (driver != null) - driver.close(); + driver?.close(); }); test('Ensure keyboard dismissal resizes the view to original size', () async { diff --git a/dev/integration_tests/ui/test_driver/route_test.dart b/dev/integration_tests/ui/test_driver/route_test.dart new file mode 100644 index 0000000000000..fa06384398e41 --- /dev/null +++ b/dev/integration_tests/ui/test_driver/route_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_driver/flutter_driver.dart'; +import 'package:test/test.dart'; + +void main() { + group('flutter run test --route', () { + FlutterDriver driver; + + setUpAll(() async { + driver = await FlutterDriver.connect(); + }); + + tearDownAll(() async { + driver?.close(); + }); + + test('sanity check flutter drive --route', () async { + // This only makes sense if you ran the test as described + // in the test file. It's normally run from devicelab. + expect(await driver.requestData('route'), '/smuggle-it'); + }); + }); +} diff --git a/dev/manual_tests/lib/material_arc.dart b/dev/manual_tests/lib/material_arc.dart index b6d71f671f28f..4846ab6eea993 100644 --- a/dev/manual_tests/lib/material_arc.dart +++ b/dev/manual_tests/lib/material_arc.dart @@ -475,6 +475,6 @@ class _AnimationDemoState extends State with TickerProviderStateM void main() { runApp(new MaterialApp( - home: const AnimationDemo() + home: const AnimationDemo(), )); } diff --git a/dev/tools/dartdoc.dart b/dev/tools/dartdoc.dart index fab1c4e2fc2df..43947eb19fbd5 100644 --- a/dev/tools/dartdoc.dart +++ b/dev/tools/dartdoc.dart @@ -200,6 +200,11 @@ void addHtmlBaseToIndex() { 'href="Android/Android-library.html"', 'href="https://docs.flutter.io/javadoc/"' ); + indexContents = indexContents.replaceAll( + 'href="iOS/iOS-library.html"', + 'href="https://docs.flutter.io/objc/"' + ); + indexFile.writeAsStringSync(indexContents); } @@ -243,10 +248,13 @@ Iterable libraryRefs({ bool diskPath: false }) sync* { } // Add a fake package for platform integration APIs. - if (diskPath) + if (diskPath) { yield 'platform_integration/lib/android.dart'; - else + yield 'platform_integration/lib/ios.dart'; + } else { yield 'platform_integration/android.dart'; + yield 'platform_integration/ios.dart'; + } } void printStream(Stream> stream) { diff --git a/dev/tools/java_and_objc_doc.dart b/dev/tools/java_and_objc_doc.dart new file mode 100644 index 0000000000000..4a59b2100578e --- /dev/null +++ b/dev/tools/java_and_objc_doc.dart @@ -0,0 +1,53 @@ +// Copyright 2017 The Chromium 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:async'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:http/http.dart' as http; + +const String kDocRoot = 'dev/docs/doc'; + +/// This script downloads an archive of Javadoc and objc doc for the engine from +/// the artifact store and extracts them to the location used for Dartdoc. +Future main(List args) async { + final String engineVersion = + new File('bin/internal/engine.version').readAsStringSync().trim(); + + final String javadocUrl = + 'https://storage.googleapis.com/flutter_infra/flutter/$engineVersion/android-javadoc.zip'; + generateDocs(javadocUrl, 'javadoc', 'io/flutter/view/FlutterView.html'); + + final String objcdocUrl = + 'https://storage.googleapis.com/flutter_infra/flutter/$engineVersion/ios-objcdoc.zip'; + generateDocs( + objcdocUrl, 'objcdoc', 'objc/Classes/FlutterViewController.html'); +} + +Future generateDocs( + final String url, String docName, String checkFile) async { + final http.Response response = await http.get(url); + + final Archive archive = new ZipDecoder().decodeBytes(response.bodyBytes); + + final Directory output = new Directory('$kDocRoot/$docName'); + print('Extracing $docName to ${output.path}'); + output.createSync(recursive: true); + + for (ArchiveFile af in archive) { + if (af.isFile) { + final File file = new File('${output.path}/${af.name}'); + file.createSync(recursive: true); + file.writeAsBytesSync(af.content); + } + } + + final File testFile = new File('${output.path}/$checkFile'); + if (!testFile.existsSync()) { + print('Expected file ${testFile.path} not found'); + exit(1); + } + print('$docName ready to go!'); +} diff --git a/dev/tools/javadoc.dart b/dev/tools/javadoc.dart deleted file mode 100644 index 2b99f0eb560db..0000000000000 --- a/dev/tools/javadoc.dart +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2017 The Chromium 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:async'; -import 'dart:io'; - -import 'package:archive/archive.dart'; -import 'package:http/http.dart' as http; - -const String kDocRoot = 'dev/docs/doc'; - -/// This script downloads an archive of Javadoc for the engine from the -/// artifact store and extracts it to the location used for Dartdoc. -Future main(List args) async { - final String engineVersion = new File('bin/internal/engine.version').readAsStringSync().trim(); - - final String url = 'https://storage.googleapis.com/flutter_infra/flutter/$engineVersion/android-javadoc.zip'; - final http.Response response = await http.get(url); - - final Archive archive = new ZipDecoder().decodeBytes(response.bodyBytes); - - final Directory output = new Directory('$kDocRoot/javadoc'); - print('Extracing javadoc to ${output.path}'); - output.createSync(recursive: true); - - for (ArchiveFile af in archive) { - if (af.isFile) { - final File file = new File('${output.path}/${af.name}'); - file.createSync(recursive: true); - file.writeAsBytesSync(af.content); - } - } - - final File testFile = new File('${output.path}/io/flutter/view/FlutterView.html'); - if (!testFile.existsSync()) { - print('Expected file ${testFile.path} not found'); - exit(1); - } - print('Javadocs ready to go!'); -} diff --git a/examples/catalog/bin/screenshot_test.dart.template b/examples/catalog/bin/screenshot_test.dart.template index 2b283773d3d0e..a924cd06b5673 100644 --- a/examples/catalog/bin/screenshot_test.dart.template +++ b/examples/catalog/bin/screenshot_test.dart.template @@ -22,6 +22,7 @@ void main() { final List paths = [ @(paths) ]; + await driver.waitUntilNoTransientCallbacks(); for (String path in paths) { final List pixels = await driver.screenshot(); final File file = new File(path); diff --git a/examples/stocks/lib/main.dart b/examples/stocks/lib/main.dart index 1cbfd1a55a48c..46ed17d2dd8db 100644 --- a/examples/stocks/lib/main.dart +++ b/examples/stocks/lib/main.dart @@ -29,9 +29,7 @@ class StocksApp extends StatefulWidget { } class StocksAppState extends State { - - final Map _stocks = {}; - final List _symbols = []; + StockData stocks; StockConfiguration _configuration = new StockConfiguration( stockMode: StockMode.optimistic, @@ -49,11 +47,7 @@ class StocksAppState extends State { @override void initState() { super.initState(); - new StockDataFetcher((StockData data) { - setState(() { - data.appendTo(_stocks, _symbols); - }); - }); + stocks = new StockData(); } void configurationUpdater(StockConfiguration value) { @@ -80,19 +74,28 @@ class StocksAppState extends State { } Route _getRoute(RouteSettings settings) { + // Routes, by convention, are split on slashes, like filesystem paths. final List path = settings.name.split('/'); + // We only support paths that start with a slash, so bail if + // the first component is not empty: if (path[0] != '') return null; - if (path[1] == 'stock') { - if (path.length != 3) + // If the path is "/stock:..." then show a stock page for the + // specified stock symbol. + if (path[1].startsWith('stock:')) { + // We don't yet support subpages of a stock, so bail if there's + // any more path components. + if (path.length != 2) return null; - if (_stocks.containsKey(path[2])) { - return new MaterialPageRoute( - settings: settings, - builder: (BuildContext context) => new StockSymbolPage(stock: _stocks[path[2]]) - ); - } + // Extract the symbol part of "stock:..." and return a route + // for that symbol. + final String symbol = path[1].substring(6); + return new MaterialPageRoute( + settings: settings, + builder: (BuildContext context) => new StockSymbolPage(symbol: symbol, stocks: stocks), + ); } + // The other paths we support are in the routes table. return null; } @@ -120,7 +123,7 @@ class StocksAppState extends State { showPerformanceOverlay: _configuration.showPerformanceOverlay, showSemanticsDebugger: _configuration.showSemanticsDebugger, routes: { - '/': (BuildContext context) => new StockHome(_stocks, _symbols, _configuration, configurationUpdater), + '/': (BuildContext context) => new StockHome(stocks, _configuration, configurationUpdater), '/settings': (BuildContext context) => new StockSettings(_configuration, configurationUpdater) }, onGenerateRoute: _getRoute, diff --git a/examples/stocks/lib/stock_data.dart b/examples/stocks/lib/stock_data.dart index d84c37d305444..57243b672fe25 100644 --- a/examples/stocks/lib/stock_data.dart +++ b/examples/stocks/lib/stock_data.dart @@ -10,6 +10,7 @@ import 'dart:convert'; import 'dart:math' as math; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; @@ -38,54 +39,64 @@ class Stock { } } -class StockData { - StockData(this._data); +class StockData extends ChangeNotifier { + StockData() { + if (actuallyFetchData) { + _httpClient = createHttpClient(); + _fetchNextChunk(); + } + } + + final List _symbols = []; + final Map _stocks = {}; + + Iterable get allSymbols => _symbols; - final List> _data; + Stock operator [](String symbol) => _stocks[symbol]; - void appendTo(Map stocks, List symbols) { - for (List fields in _data) { + bool get loading => _httpClient != null; + + void add(List> data) { + for (List fields in data) { final Stock stock = new Stock.fromFields(fields); - symbols.add(stock.symbol); - stocks[stock.symbol] = stock; + _symbols.add(stock.symbol); + _stocks[stock.symbol] = stock; } - symbols.sort(); + _symbols.sort(); + notifyListeners(); } -} - -typedef void StockDataCallback(StockData data); -const int _kChunkCount = 30; -String _urlToFetch(int chunk) { - return 'https://domokit.github.io/examples/stocks/data/stock_data_$chunk.json'; -} + static const int _kChunkCount = 30; + int _nextChunk = 0; -class StockDataFetcher { - StockDataFetcher(this.callback) { - _httpClient = createHttpClient(); - _fetchNextChunk(); + String _urlToFetch(int chunk) { + return 'https://domokit.github.io/examples/stocks/data/stock_data_$chunk.json'; } - final StockDataCallback callback; http.Client _httpClient; static bool actuallyFetchData = true; - int _nextChunk = 0; - void _fetchNextChunk() { - if (!actuallyFetchData) - return; _httpClient.get(_urlToFetch(_nextChunk++)).then((http.Response response) { final String json = response.body; if (json == null) { - print("Failed to load stock data chunk ${_nextChunk - 1}"); - return null; + debugPrint('Failed to load stock data chunk ${_nextChunk - 1}'); + _end(); + return; } final JsonDecoder decoder = const JsonDecoder(); - callback(new StockData(decoder.convert(json))); - if (_nextChunk < _kChunkCount) + add(decoder.convert(json)); + if (_nextChunk < _kChunkCount) { _fetchNextChunk(); + } else { + _end(); + } }); } + + void _end() { + _httpClient?.close(); + _httpClient = null; + } } diff --git a/examples/stocks/lib/stock_home.dart b/examples/stocks/lib/stock_home.dart index 1066cad088a81..340ba0568674c 100644 --- a/examples/stocks/lib/stock_home.dart +++ b/examples/stocks/lib/stock_home.dart @@ -50,10 +50,9 @@ class _NotImplementedDialog extends StatelessWidget { } class StockHome extends StatefulWidget { - const StockHome(this.stocks, this.symbols, this.configuration, this.updater); + const StockHome(this.stocks, this.configuration, this.updater); - final Map stocks; - final List symbols; + final StockData stocks; final StockConfiguration configuration; final ValueChanged updater; @@ -62,10 +61,9 @@ class StockHome extends StatefulWidget { } class StockHomeState extends State { - final GlobalKey _scaffoldKey = new GlobalKey(); - bool _isSearching = false; final TextEditingController _searchQuery = new TextEditingController(); + bool _isSearching = false; bool _autorefresh = false; void _handleSearchBegin() { @@ -82,10 +80,6 @@ class StockHomeState extends State { }); } - void _handleSearchEnd() { - Navigator.pop(context); - } - void _handleStockModeChange(StockMode value) { if (widget.updater != null) widget.updater(widget.configuration.copyWith(stockMode: value)); @@ -233,8 +227,8 @@ class StockHomeState extends State { ); } - Iterable _getStockList(Iterable symbols) { - return symbols.map((String symbol) => widget.stocks[symbol]) + static Iterable _getStockList(StockData stocks, Iterable symbols) { + return symbols.map((String symbol) => stocks[symbol]) .where((Stock stock) => stock != null); } @@ -266,7 +260,7 @@ class StockHomeState extends State { stocks: stocks.toList(), onAction: _buyStock, onOpen: (Stock stock) { - Navigator.pushNamed(context, '/stock/${stock.symbol}'); + Navigator.pushNamed(context, '/stock:${stock.symbol}'); }, onShow: (Stock stock) { _scaffoldKey.currentState.showBottomSheet((BuildContext context) => new StockSymbolBottomSheet(stock: stock)); @@ -275,22 +269,21 @@ class StockHomeState extends State { } Widget _buildStockTab(BuildContext context, StockHomeTab tab, List stockSymbols) { - return new Container( + return new AnimatedBuilder( key: new ValueKey(tab), - child: _buildStockList(context, _filterBySearchQuery(_getStockList(stockSymbols)).toList(), tab), + animation: new Listenable.merge([_searchQuery, widget.stocks]), + builder: (BuildContext context, Widget child) { + return _buildStockList(context, _filterBySearchQuery(_getStockList(widget.stocks, stockSymbols)).toList(), tab); + }, ); } static const List portfolioSymbols = const ["AAPL","FIZZ", "FIVE", "FLAT", "ZINC", "ZNGA"]; - // TODO(abarth): Should we factor this into a SearchBar in the framework? Widget buildSearchBar() { return new AppBar( - leading: new IconButton( - icon: const Icon(Icons.arrow_back), + leading: new BackButton( color: Theme.of(context).accentColor, - onPressed: _handleSearchEnd, - tooltip: 'Back', ), title: new TextField( controller: _searchQuery, @@ -330,7 +323,7 @@ class StockHomeState extends State { drawer: _buildDrawer(context), body: new TabBarView( children: [ - _buildStockTab(context, StockHomeTab.market, widget.symbols), + _buildStockTab(context, StockHomeTab.market, widget.stocks.allSymbols), _buildStockTab(context, StockHomeTab.portfolio, portfolioSymbols), ], ), @@ -342,7 +335,6 @@ class StockHomeState extends State { class _CreateCompanySheet extends StatelessWidget { @override Widget build(BuildContext context) { - // TODO(ianh): Fill this out. return new Column( children: [ const TextField( @@ -351,6 +343,9 @@ class _CreateCompanySheet extends StatelessWidget { hintText: 'Company Name', ), ), + const Text('(This demo is not yet complete.)'), + // For example, we could add a button that actually updates the list + // and then contacts the server, etc. ], ); } diff --git a/examples/stocks/lib/stock_symbol_viewer.dart b/examples/stocks/lib/stock_symbol_viewer.dart index 6d299196b8930..27095ce751ccc 100644 --- a/examples/stocks/lib/stock_symbol_viewer.dart +++ b/examples/stocks/lib/stock_symbol_viewer.dart @@ -15,6 +15,7 @@ class _StockSymbolView extends StatelessWidget { @override Widget build(BuildContext context) { + assert(stock != null); final String lastSale = "\$${stock.lastSale.toStringAsFixed(2)}"; String changeInPrice = "${stock.percentChange.toStringAsFixed(2)}%"; if (stock.percentChange > 0) @@ -63,30 +64,49 @@ class _StockSymbolView extends StatelessWidget { } class StockSymbolPage extends StatelessWidget { - const StockSymbolPage({ this.stock }); + const StockSymbolPage({ this.symbol, this.stocks }); - final Stock stock; + final String symbol; + final StockData stocks; @override Widget build(BuildContext context) { - return new Scaffold( - appBar: new AppBar( - title: new Text(stock.name) - ), - body: new SingleChildScrollView( - child: new Container( - margin: const EdgeInsets.all(20.0), - child: new Card( - child: new _StockSymbolView( - stock: stock, - arrow: new Hero( - tag: stock, - child: new StockArrow(percentChange: stock.percentChange) + return new AnimatedBuilder( + animation: stocks, + builder: (BuildContext context, Widget child) { + final Stock stock = stocks[symbol]; + return new Scaffold( + appBar: new AppBar( + title: new Text(stock?.name ?? symbol) + ), + body: new SingleChildScrollView( + child: new Container( + margin: const EdgeInsets.all(20.0), + child: new Card( + child: new AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + firstChild: const Padding( + padding: const EdgeInsets.all(20.0), + child: const Center(child: const CircularProgressIndicator()), + ), + secondChild: stock != null + ? new _StockSymbolView( + stock: stock, + arrow: new Hero( + tag: stock, + child: new StockArrow(percentChange: stock.percentChange), + ), + ) : new Padding( + padding: const EdgeInsets.all(20.0), + child: new Center(child: new Text('$symbol not found')), + ), + crossFadeState: stock == null && stocks.loading ? CrossFadeState.showFirst : CrossFadeState.showSecond, + ), ) ) ) - ) - ) + ); + }, ); } } diff --git a/examples/stocks/test/icon_color_test.dart b/examples/stocks/test/icon_color_test.dart index e2dddd6dbec31..6d2018c0ebcca 100644 --- a/examples/stocks/test/icon_color_test.dart +++ b/examples/stocks/test/icon_color_test.dart @@ -46,9 +46,9 @@ void checkIconColor(WidgetTester tester, String label, Color color) { } void main() { - stock_data.StockDataFetcher.actuallyFetchData = false; + stock_data.StockData.actuallyFetchData = false; - testWidgets("Test icon colors", (WidgetTester tester) async { + testWidgets('Icon colors', (WidgetTester tester) async { stocks.main(); // builds the app and schedules a frame but doesn't trigger one await tester.pump(); // see https://github.com/flutter/flutter/issues/1865 await tester.pump(); // triggers a frame diff --git a/examples/stocks/test/locale_test.dart b/examples/stocks/test/locale_test.dart index 2dc30ecf0bed8..e9e6b5d378d18 100644 --- a/examples/stocks/test/locale_test.dart +++ b/examples/stocks/test/locale_test.dart @@ -7,14 +7,14 @@ import 'package:stocks/main.dart' as stocks; import 'package:stocks/stock_data.dart' as stock_data; void main() { - stock_data.StockDataFetcher.actuallyFetchData = false; + stock_data.StockData.actuallyFetchData = false; - testWidgets("Test changing locale", (WidgetTester tester) async { + testWidgets('Changing locale', (WidgetTester tester) async { stocks.main(); await tester.idle(); // see https://github.com/flutter/flutter/issues/1865 await tester.pump(); expect(find.text('MARKET'), findsOneWidget); - await tester.binding.setLocale("es", "US"); + await tester.binding.setLocale('es', 'US'); await tester.idle(); await tester.pump(); expect(find.text('MERCADO'), findsOneWidget); diff --git a/examples/stocks/test/search_test.dart b/examples/stocks/test/search_test.dart new file mode 100644 index 0000000000000..336327a1a4ad8 --- /dev/null +++ b/examples/stocks/test/search_test.dart @@ -0,0 +1,53 @@ +// Copyright 2016 The Chromium 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'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stocks/main.dart' as stocks; +import 'package:stocks/stock_data.dart' as stock_data; + +void main() { + stock_data.StockData.actuallyFetchData = false; + + testWidgets('Search', (WidgetTester tester) async { + stocks.main(); // builds the app and schedules a frame but doesn't trigger one + await tester.pump(); // see https://github.com/flutter/flutter/issues/1865 + await tester.pump(); // triggers a frame + + expect(find.text('AAPL'), findsNothing); + expect(find.text('BANA'), findsNothing); + + final stocks.StocksAppState app = tester.state(find.byType(stocks.StocksApp)); + app.stocks.add(>[ + // "Symbol","Name","LastSale","MarketCap","IPOyear","Sector","industry","Summary Quote" + ['AAPL', 'Apple', '', '', '', '', '', ''], + ['BANA', 'Banana', '', '', '', '', '', ''], + ]); + await tester.pump(); + + expect(find.text('AAPL'), findsOneWidget); + expect(find.text('BANA'), findsOneWidget); + + await tester.tap(find.byTooltip('Search')); + // We skip a minute at a time so that each phase of the animation + // is done in two frames, the start frame and the end frame. + // There are two phases currently, so that results in three frames. + expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 3); + + expect(find.text('AAPL'), findsOneWidget); + expect(find.text('BANA'), findsOneWidget); + + await tester.enterText(find.byType(EditableText), 'B'); + await tester.pump(); + + expect(find.text('AAPL'), findsNothing); + expect(find.text('BANA'), findsOneWidget); + + await tester.enterText(find.byType(EditableText), 'X'); + await tester.pump(); + + expect(find.text('AAPL'), findsNothing); + expect(find.text('BANA'), findsNothing); + }); +} diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 25edf6a1e439b..8c89996eca2e4 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -42,6 +42,7 @@ export 'src/material/dropdown.dart'; export 'src/material/expand_icon.dart'; export 'src/material/expansion_panel.dart'; export 'src/material/expansion_tile.dart'; +export 'src/material/feedback.dart'; export 'src/material/flat_button.dart'; export 'src/material/flexible_space_bar.dart'; export 'src/material/floating_action_button.dart'; diff --git a/packages/flutter/lib/src/animation/animation.dart b/packages/flutter/lib/src/animation/animation.dart index 1140fe7dbf094..c62749f37ac76 100644 --- a/packages/flutter/lib/src/animation/animation.dart +++ b/packages/flutter/lib/src/animation/animation.dart @@ -81,7 +81,7 @@ abstract class Animation extends Listenable { @override String toString() { - return '$runtimeType#$hashCode(${toStringDetails()})'; + return '${describeIdentity(this)}(${toStringDetails()})'; } /// Provides a string describing the status of this object, but not including diff --git a/packages/flutter/lib/src/foundation/change_notifier.dart b/packages/flutter/lib/src/foundation/change_notifier.dart index 06dd9ea97d4af..46407cf360c49 100644 --- a/packages/flutter/lib/src/foundation/change_notifier.dart +++ b/packages/flutter/lib/src/foundation/change_notifier.dart @@ -7,6 +7,7 @@ import 'package:meta/meta.dart'; import 'assertions.dart'; import 'basic_types.dart'; import 'observer_list.dart'; +import 'tree_diagnostics_mixin.dart'; /// An object that maintains a list of listeners. abstract class Listenable { @@ -185,5 +186,5 @@ class ValueNotifier extends ChangeNotifier { } @override - String toString() => '$runtimeType#$hashCode($value)'; + String toString() => '${describeIdentity(this)}($value)'; } diff --git a/packages/flutter/lib/src/foundation/tree_diagnostics_mixin.dart b/packages/flutter/lib/src/foundation/tree_diagnostics_mixin.dart index cb69a74f6463e..76ee9d15d3302 100644 --- a/packages/flutter/lib/src/foundation/tree_diagnostics_mixin.dart +++ b/packages/flutter/lib/src/foundation/tree_diagnostics_mixin.dart @@ -6,6 +6,16 @@ import 'package:meta/meta.dart'; import 'print.dart'; +/// Returns a 5 character long hexadecimal string generated from +/// Object.hashCode's 20 least-significant bits. +String shortHash(Object object) { + return object.hashCode.toUnsigned(20).toRadixString(16).padLeft(5, '0'); +} + +/// Returns a summary of the runtime type and hash code of `object`. +String describeIdentity(Object object) => + '${object.runtimeType}#${shortHash(object)}'; + /// A mixin that helps dump string representations of trees. abstract class TreeDiagnosticsMixin { // This class is intended to be used as a mixin, and should not be @@ -20,7 +30,7 @@ abstract class TreeDiagnosticsMixin { /// * [toStringShallow], for a detailed description of the object. /// * [toStringDeep], for a description of the subtree rooted at this object. @override - String toString() => '$runtimeType#$hashCode'; + String toString() => describeIdentity(this); /// Returns a one-line detailed description of the object. /// diff --git a/packages/flutter/lib/src/gestures/recognizer.dart b/packages/flutter/lib/src/gestures/recognizer.dart index c7073cabc9529..6b545b7cdf10f 100644 --- a/packages/flutter/lib/src/gestures/recognizer.dart +++ b/packages/flutter/lib/src/gestures/recognizer.dart @@ -86,7 +86,7 @@ abstract class GestureRecognizer extends GestureArenaMember { } @override - String toString() => '$runtimeType#$hashCode'; + String toString() => describeIdentity(this); } /// Base class for gesture recognizers that can only recognize one @@ -321,5 +321,5 @@ abstract class PrimaryPointerGestureRecognizer extends OneSequenceGestureRecogni } @override - String toString() => '$runtimeType#$hashCode($state)'; + String toString() => '${describeIdentity(this)}($state)'; } diff --git a/packages/flutter/lib/src/material/app.dart b/packages/flutter/lib/src/material/app.dart index a860860d86a23..8b035d443ecb2 100644 --- a/packages/flutter/lib/src/material/app.dart +++ b/packages/flutter/lib/src/material/app.dart @@ -26,10 +26,29 @@ const TextStyle _errorTextStyle = const TextStyle( /// An application that uses material design. /// /// A convenience widget that wraps a number of widgets that are commonly -/// required for material design applications. It builds upon a -/// [WidgetsApp] by adding material-design specific functionality, such as -/// [AnimatedTheme] and [GridPaper]. This widget also configures the top-level -/// [Navigator]'s observer to perform [Hero] animations. +/// required for material design applications. It builds upon a [WidgetsApp] by +/// adding material-design specific functionality, such as [AnimatedTheme] and +/// [GridPaper]. +/// +/// The [MaterialApp] configures the top-level [Navigator] to search for routes +/// in the following order: +/// +/// 1. For the `/` route, the [home] property, if non-null, is used. +/// +/// 2. Otherwise, the [routes] table is used, if it has an entry for the route. +/// +/// 3. Otherwise, [onGenerateRoute] is called, if provided. It should return a +/// non-null value for any _valid_ route not handled by [home] and [routes]. +/// +/// 4. Finally if all else fails [onUnknownRoute] is called. +/// +/// At least one of these options must handle the `/` route, since it is used +/// when an invalid [initialRoute] is specified on startup (e.g. by another +/// application launching this one with an intent on Android; see +/// [Window.defaultRouteName]). +/// +/// This widget also configures the top-level [Navigator]'s observer to perform +/// [Hero] animations. /// /// See also: /// @@ -38,24 +57,27 @@ const TextStyle _errorTextStyle = const TextStyle( /// * [MaterialPageRoute], which defines an app page that transitions in a material-specific way. /// * [WidgetsApp], which defines the basic app elements but does not depend on the material library. class MaterialApp extends StatefulWidget { - /// Creates a MaterialApp. /// - /// At least one of [home], [routes], or [onGenerateRoute] must be - /// given. If only [routes] is given, it must include an entry for the - /// [initialRoute], which defaults to [Navigator.defaultRouteName] - /// (`'/'`). + /// At least one of [home], [routes], or [onGenerateRoute] must be given. If + /// only [routes] is given, it must include an entry for the + /// [Navigator.defaultRouteName] (`/`), since that is the route used when the + /// application is launched with an intent that specifies an otherwise + /// unsupported route. /// /// This class creates an instance of [WidgetsApp]. - MaterialApp({ + /// + /// The boolean arguments, [routes], and [navigatorObservers], must not be null. + MaterialApp({ // can't be const because the asserts use methods on Map :-( Key key, this.title, this.color, this.theme, this.home, this.routes: const {}, - this.initialRoute: Navigator.defaultRouteName, + this.initialRoute, this.onGenerateRoute, + this.onUnknownRoute, this.onLocaleChanged, this.navigatorObservers: const [], this.debugShowMaterialGrid: false, @@ -64,10 +86,32 @@ class MaterialApp extends StatefulWidget { this.checkerboardOffscreenLayers: false, this.showSemanticsDebugger: false, this.debugShowCheckedModeBanner: true - }) : assert(debugShowMaterialGrid != null), - assert(routes != null), - assert(!routes.containsKey(initialRoute) || (home == null)), - assert(routes.containsKey(initialRoute) || (home != null) || (onGenerateRoute != null)), + }) : assert(routes != null), + assert(navigatorObservers != null), + assert(debugShowMaterialGrid != null), + assert(showPerformanceOverlay != null), + assert(checkerboardRasterCacheImages != null), + assert(checkerboardOffscreenLayers != null), + assert(showSemanticsDebugger != null), + assert(debugShowCheckedModeBanner != null), + assert( + home == null || + !routes.containsKey(Navigator.defaultRouteName), + 'If the home property is specified, the routes table ' + 'cannot include an entry for "/", since it would be redundant.' + ), + assert( + home != null || + routes.containsKey(Navigator.defaultRouteName) || + onGenerateRoute != null || + onUnknownRoute != null, + 'Either the home property must be specified, ' + 'or the routes table must include an entry for "/", ' + 'or there must be on onGenerateRoute callback specified, ' + 'or there must be an onUnknownRoute callback specified, ' + 'because otherwise there is nothing to fall back on if the ' + 'app is started with an intent that specifies an unknown route.' + ), super(key: key); /// A one-line description of this app for use in the window manager. @@ -76,20 +120,20 @@ class MaterialApp extends StatefulWidget { /// The colors to use for the application's widgets. final ThemeData theme; - /// The widget for the default route of the app - /// ([Navigator.defaultRouteName], which is `'/'`). + /// The widget for the default route of the app ([Navigator.defaultRouteName], + /// which is `/`). /// - /// This is the page that is displayed first when the application is - /// started normally. + /// This is the route that is displayed first when the application is started + /// normally, unless [initialRoute] is specified. It's also the route that's + /// displayed if the [initialRoute] can't be displayed. /// /// To be able to directly call [Theme.of], [MediaQuery.of], /// [LocaleQuery.of], etc, in the code sets the [home] argument in /// the constructor, you can use a [Builder] widget to get a /// [BuildContext]. /// - /// If this is not specified, then either the route with name `'/'` - /// must be given in [routes], or the [onGenerateRoute] callback - /// must be able to build a widget for that route. + /// If [home] is specified, then [routes] must not include an entry for `/`, + /// as [home] takes its place. final Widget home; /// The primary color to use for the application in the operating system @@ -108,29 +152,71 @@ class MaterialApp extends StatefulWidget { /// /// If the app only has one page, then you can specify it using [home] instead. /// - /// If [home] is specified, then it is an error to provide a route - /// in this map for the [Navigator.defaultRouteName] route (`'/'`). + /// If [home] is specified, then it implies an entry in this table for the + /// [Navigator.defaultRouteName] route (`/`), and it is an error to + /// redundantly provide such a route in the [routes] table. /// - /// If a route is requested that is not specified in this table (or - /// by [home]), then the [onGenerateRoute] callback is called to - /// build the page instead. + /// If a route is requested that is not specified in this table (or by + /// [home]), then the [onGenerateRoute] callback is called to build the page + /// instead. final Map routes; /// The name of the first route to show. /// - /// Defaults to [Window.defaultRouteName]. + /// Defaults to [Window.defaultRouteName], which may be overridden by the code + /// that launched the application. + /// + /// If the route contains slashes, then it is treated as a "deep link", and + /// before this route is pushed, the routes leading to this one are pushed + /// also. For example, if the route was `/a/b/c`, then the app would start + /// with the three routes `/a`, `/a/b`, and `/a/b/c` loaded, in that order. + /// + /// If any part of this process fails to generate routes, then the + /// [initialRoute] is ignored and [Navigator.defaultRouteName] is used instead + /// (`/`). This can happen if the app is started with an intent that specifies + /// a non-existent route. + /// + /// See also: + /// + /// * [Navigator.initialRoute], which is used to implement this property. + /// * [Navigator.push], for pushing additional routes. + /// * [Navigator.pop], for removing a route from the stack. final String initialRoute; /// The route generator callback used when the app is navigated to a /// named route. + /// + /// This is used if [routes] does not contain the requested route. + /// + /// If this returns null when building the routes to handle the specified + /// [initialRoute], then all the routes are discarded and + /// [Navigator.defaultRouteName] is used instead (`/`). See [initialRoute]. + /// + /// During normal app operation, the [onGenerateRoute] callback will only be + /// applied to route names pushed by the application, and so should never + /// return null. final RouteFactory onGenerateRoute; + /// Called when [onGenerateRoute] fails to generate a route, except for the + /// [initialRoute]. + /// + /// This callback is typically used for error handling. For example, this + /// callback might always generate a "not found" page that describes the route + /// that wasn't found. + /// + /// The default implementation pushes a route that displays an ugly error + /// message. + final RouteFactory onUnknownRoute; + /// Callback that is called when the operating system changes the /// current locale. final LocaleChangedCallback onLocaleChanged; /// Turns on a performance overlay. - /// https://flutter.io/debugging/#performanceoverlay + /// + /// See also: + /// + /// * final bool showPerformanceOverlay; /// Turns on checkerboarding of raster cache images. @@ -162,9 +248,13 @@ class MaterialApp extends StatefulWidget { final List navigatorObservers; /// Turns on a [GridPaper] overlay that paints a baseline grid - /// Material apps: - /// https://material.google.com/layout/metrics-keylines.html + /// Material apps. + /// /// Only available in checked mode. + /// + /// See also: + /// + /// * final bool debugShowMaterialGrid; @override @@ -210,13 +300,16 @@ class _MaterialAppState extends State { } Route _onGenerateRoute(RouteSettings settings) { - WidgetBuilder builder = widget.routes[settings.name]; - if (builder == null && widget.home != null && settings.name == Navigator.defaultRouteName) + final String name = settings.name; + WidgetBuilder builder; + if (name == Navigator.defaultRouteName && widget.home != null) builder = (BuildContext context) => widget.home; + else + builder = widget.routes[name]; if (builder != null) { return new MaterialPageRoute( builder: builder, - settings: settings + settings: settings, ); } if (widget.onGenerateRoute != null) @@ -224,6 +317,38 @@ class _MaterialAppState extends State { return null; } + Route _onUnknownRoute(RouteSettings settings) { + assert(() { + if (widget.onUnknownRoute == null) { + throw new FlutterError( + 'Could not find a generator for route $settings in the $runtimeType.\n' + 'Generators for routes are searched for in the following order:\n' + ' 1. For the "/" route, the "home" property, if non-null, is used.\n' + ' 2. Otherwise, the "routes" table is used, if it has an entry for ' + 'the route.\n' + ' 3. Otherwise, onGenerateRoute is called. It should return a ' + 'non-null value for any valid route not handled by "home" and "routes".\n' + ' 4. Finally if all else fails onUnknownRoute is called.\n' + 'Unfortunately, onUnknownRoute was not set.' + ); + } + return true; + }); + final Route result = widget.onUnknownRoute(settings); + assert(() { + if (result == null) { + throw new FlutterError( + 'The onUnknownRoute callback returned null.\n' + 'When the $runtimeType requested the route $settings from its ' + 'onUnknownRoute callback, the callback returned null. Such callbacks ' + 'must never return null.' + ); + } + return true; + }); + return result; + } + @override Widget build(BuildContext context) { final ThemeData theme = widget.theme ?? new ThemeData.fallback(); @@ -241,6 +366,7 @@ class _MaterialAppState extends State { ..add(_heroController), initialRoute: widget.initialRoute, onGenerateRoute: _onGenerateRoute, + onUnknownRoute: _onUnknownRoute, onLocaleChanged: widget.onLocaleChanged, showPerformanceOverlay: widget.showPerformanceOverlay, checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages, diff --git a/packages/flutter/lib/src/material/app_bar.dart b/packages/flutter/lib/src/material/app_bar.dart index aecefbda92a09..eece44cc5f76f 100644 --- a/packages/flutter/lib/src/material/app_bar.dart +++ b/packages/flutter/lib/src/material/app_bar.dart @@ -604,7 +604,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { @override String toString() { - return '$runtimeType#$hashCode(topPadding: ${topPadding.toStringAsFixed(1)}, bottomHeight: ${_bottomHeight.toStringAsFixed(1)}, ...)'; + return '${describeIdentity(this)}(topPadding: ${topPadding.toStringAsFixed(1)}, bottomHeight: ${_bottomHeight.toStringAsFixed(1)}, ...)'; } } diff --git a/packages/flutter/lib/src/material/back_button.dart b/packages/flutter/lib/src/material/back_button.dart index 671bfeeb8721f..57977c4f1f38d 100644 --- a/packages/flutter/lib/src/material/back_button.dart +++ b/packages/flutter/lib/src/material/back_button.dart @@ -69,12 +69,19 @@ class BackButtonIcon extends StatelessWidget { class BackButton extends StatelessWidget { /// Creates an [IconButton] with the appropriate "back" icon for the current /// target platform. - const BackButton({ Key key }) : super(key: key); + const BackButton({ Key key, this.color }) : super(key: key); + + /// The color to use for the icon. + /// + /// Defaults to the [IconThemeData.color] specified in the ambient [IconTheme], + /// which usually matches the ambient [Theme]'s [ThemeData.iconTheme]. + final Color color; @override Widget build(BuildContext context) { return new IconButton( icon: const BackButtonIcon(), + color: color, tooltip: 'Back', // TODO(ianh): Figure out how to localize this string onPressed: () { Navigator.of(context).maybePop(); diff --git a/packages/flutter/lib/src/material/chip.dart b/packages/flutter/lib/src/material/chip.dart index 86671bcdfcbc5..5ad63e2138eb4 100644 --- a/packages/flutter/lib/src/material/chip.dart +++ b/packages/flutter/lib/src/material/chip.dart @@ -7,6 +7,7 @@ import 'package:flutter/widgets.dart'; import 'colors.dart'; import 'debug.dart'; +import 'feedback.dart'; import 'icons.dart'; import 'tooltip.dart'; @@ -102,7 +103,7 @@ class Chip extends StatelessWidget { if (deletable) { rightPadding = 0.0; children.add(new GestureDetector( - onTap: onDeleted, + onTap: Feedback.wrapForTap(onDeleted, context), child: new Tooltip( message: 'Delete "$label"', child: new Container( diff --git a/packages/flutter/lib/src/material/date_picker.dart b/packages/flutter/lib/src/material/date_picker.dart index 7fcca7c469cad..76e6c65ebf5b5 100644 --- a/packages/flutter/lib/src/material/date_picker.dart +++ b/packages/flutter/lib/src/material/date_picker.dart @@ -17,6 +17,7 @@ import 'button_bar.dart'; import 'colors.dart'; import 'debug.dart'; import 'dialog.dart'; +import 'feedback.dart'; import 'flat_button.dart'; import 'icon_button.dart'; import 'icons.dart'; @@ -120,11 +121,11 @@ class _DatePickerHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ new GestureDetector( - onTap: () => _handleChangeMode(_DatePickerMode.year), + onTap: Feedback.wrapForTap(() => _handleChangeMode(_DatePickerMode.year), context), child: new Text(new DateFormat('yyyy').format(selectedDate), style: yearStyle), ), new GestureDetector( - onTap: () => _handleChangeMode(_DatePickerMode.day), + onTap: Feedback.wrapForTap(() => _handleChangeMode(_DatePickerMode.day), context), child: new Text(new DateFormat('E, MMM\u00a0d').format(selectedDate), style: dayStyle), ), ], diff --git a/packages/flutter/lib/src/material/feedback.dart b/packages/flutter/lib/src/material/feedback.dart new file mode 100644 index 0000000000000..fea6776a95823 --- /dev/null +++ b/packages/flutter/lib/src/material/feedback.dart @@ -0,0 +1,154 @@ +// Copyright 2017 The Chromium 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:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// Provides platform-specific acoustic and/or haptic feedback for certain +/// actions. +/// +/// For example, to play the Android-typically click sound when a button is +/// tapped, call [forTap]. For the Android-specific vibration when long pressing +/// an element, call [forLongPress]. Alternatively, you can also wrap your +/// [onTap] or [onLongPress] callback in [wrapForTap] or [wrapForLongPress] to +/// achieve the same (see example code below). +/// +/// Calling any of these methods is a no-op on iOS as actions on that platform +/// typically don't provide haptic or acoustic feedback. +/// +/// All methods in this class are usually called from within a [build] method +/// or from a State's methods as you have to provide a [BuildContext]. +/// +/// ## Sample code +/// +/// To trigger platform-specific feedback before executing the actual callback: +/// +/// ```dart +/// class WidgetWithWrappedHandler extends StatelessWidget { +/// @override +/// Widget build(BuildContext context) { +/// return new GestureDetector( +/// onTap: Feedback.wrapForTap(_onTapHandler, context), +/// onLongPress: Feedback.wrapForLongPress(_onLongPressHandler, context), +/// child: const Text('X'), +/// ); +/// } +/// +/// void _onTapHandler() { +/// // Respond to tap. +/// } +/// +/// void _onLongPressHandler() { +/// // Respond to long press. +/// } +/// } +/// ``` +/// +/// Alternatively, you can also call [forTap] or [forLongPress] directly within +/// your tap or long press handler: +/// +/// ```dart +/// class WidgetWithExplicitCall extends StatelessWidget { +/// @override +/// Widget build(BuildContext context) { +/// return new GestureDetector( +/// onTap: () { +/// // Do some work (e.g. check if the tap is valid) +/// Feedback.forTap(context); +/// // Do more work (e.g. respond to the tap) +/// }, +/// onLongPress: () { +/// // Do some work (e.g. check if the long press is valid) +/// Feedback.forLongPress(context); +/// // Do more work (e.g. respond to the long press) +/// }, +/// child: const Text('X'), +/// ); +/// } +/// } +/// ``` +class Feedback { + Feedback._(); + + /// Provides platform-specific feedback for a tap. + /// + /// On Android the click system sound is played. On iOS this is a no-op. + /// + /// See also: + /// + /// * [wrapForTap] to trigger platform-specific feedback before executing a + /// [GestureTapCallback]. + static Future forTap(BuildContext context) async { + switch (_platform(context)) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + return SystemSound.play(SystemSoundType.click); + default: + return new Future.value(); + } + } + + /// Wraps a [GestureTapCallback] to provide platform specific feedback for a + /// tap before the provided callback is executed. + /// + /// On Android the platform-typical click system sound is played. On iOS this + /// is a no-op as that platform usually doesn't provide feedback for a tap. + /// + /// See also: + /// + /// * [forTap] to just trigger the platform-specific feedback without wrapping + /// a [GestureTapCallback]. + static GestureTapCallback wrapForTap(GestureTapCallback callback, BuildContext context) { + if (callback == null) + return null; + return () { + Feedback.forTap(context); + callback(); + }; + } + + /// Provides platform-specific feedback for a long press. + /// + /// On Android the platform-typical vibration is triggered. On iOS this is a + /// no-op as that platform usually doesn't provide feedback for long presses. + /// + /// See also: + /// + /// * [wrapForLongPress] to trigger platform-specific feedback before + /// executing a [GestureLongPressCallback]. + static Future forLongPress(BuildContext context) { + switch (_platform(context)) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + return HapticFeedback.vibrate(); + default: + return new Future.value(); + } + } + + /// Wraps a [GestureLongPressCallback] to provide platform specific feedback + /// for a long press before the provided callback is executed. + /// + /// On Android the platform-typical vibration is triggered. On iOS this + /// is a no-op as that platform usually doesn't provide feedback for a long + /// press. + /// + /// See also: + /// + /// * [forLongPress] to just trigger the platform-specific feedback without + /// wrapping a [GestureLongPressCallback]. + static GestureLongPressCallback wrapForLongPress(GestureLongPressCallback callback, BuildContext context) { + if (callback == null) + return null; + return () { + Feedback.forLongPress(context); + callback(); + }; + } + + static TargetPlatform _platform(BuildContext context) => Theme.of(context).platform; +} \ No newline at end of file diff --git a/packages/flutter/lib/src/material/ink_well.dart b/packages/flutter/lib/src/material/ink_well.dart index e003efed8b90c..e52527249360b 100644 --- a/packages/flutter/lib/src/material/ink_well.dart +++ b/packages/flutter/lib/src/material/ink_well.dart @@ -10,6 +10,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'debug.dart'; +import 'feedback.dart'; import 'ink_highlight.dart'; import 'ink_splash.dart'; import 'material.dart'; @@ -70,6 +71,7 @@ import 'theme.dart'; /// ```dart /// assert(debugCheckHasMaterial(context)); /// ``` +/// The parameter [enableFeedback] must not be `null`. /// /// See also: /// @@ -93,7 +95,8 @@ class InkResponse extends StatefulWidget { this.borderRadius: BorderRadius.zero, this.highlightColor, this.splashColor, - }) : super(key: key); + this.enableFeedback: true, + }) : assert(enableFeedback != null), super(key: key); /// The widget below this widget in the tree. final Widget child; @@ -179,6 +182,16 @@ class InkResponse extends StatefulWidget { /// * [highlightColor], the color of the highlight. final Color splashColor; + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool enableFeedback; + /// The rectangle to use for the highlight effect and for clipping /// the splash effects if [containedInkWell] is true. /// @@ -288,12 +301,15 @@ class _InkResponseState extends State { updateHighlight(true); } - void _handleTap() { + void _handleTap(BuildContext context) { _currentSplash?.confirm(); _currentSplash = null; updateHighlight(false); - if (widget.onTap != null) + if (widget.onTap != null) { + if (widget.enableFeedback) + Feedback.forTap(context); widget.onTap(); + } } void _handleTapCancel() { @@ -309,11 +325,14 @@ class _InkResponseState extends State { widget.onDoubleTap(); } - void _handleLongPress() { + void _handleLongPress(BuildContext context) { _currentSplash?.confirm(); _currentSplash = null; - if (widget.onLongPress != null) + if (widget.onLongPress != null) { + if (widget.enableFeedback) + Feedback.forLongPress(context); widget.onLongPress(); + } } @override @@ -340,10 +359,10 @@ class _InkResponseState extends State { final bool enabled = widget.onTap != null || widget.onDoubleTap != null || widget.onLongPress != null; return new GestureDetector( onTapDown: enabled ? _handleTapDown : null, - onTap: enabled ? _handleTap : null, + onTap: enabled ? () => _handleTap(context) : null, onTapCancel: enabled ? _handleTapCancel : null, onDoubleTap: widget.onDoubleTap != null ? _handleDoubleTap : null, - onLongPress: widget.onLongPress != null ? _handleLongPress : null, + onLongPress: widget.onLongPress != null ? () => _handleLongPress(context) : null, behavior: HitTestBehavior.opaque, child: widget.child ); @@ -392,6 +411,7 @@ class InkWell extends InkResponse { Color highlightColor, Color splashColor, BorderRadius borderRadius, + bool enableFeedback: true, }) : super( key: key, child: child, @@ -404,5 +424,6 @@ class InkWell extends InkResponse { highlightColor: highlightColor, splashColor: splashColor, borderRadius: borderRadius, + enableFeedback: enableFeedback, ); } diff --git a/packages/flutter/lib/src/material/material.dart b/packages/flutter/lib/src/material/material.dart index fc92a7e14255c..f041b9f1a0d34 100644 --- a/packages/flutter/lib/src/material/material.dart +++ b/packages/flutter/lib/src/material/material.dart @@ -425,5 +425,5 @@ abstract class InkFeature { void paintFeature(Canvas canvas, Matrix4 transform); @override - String toString() => '$runtimeType#$hashCode'; + String toString() => describeIdentity(this); } diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 46fb6fe34c0cc..53e9bacf90f44 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import 'feedback.dart'; import 'input_decorator.dart'; import 'text_selection.dart'; import 'theme.dart'; @@ -220,6 +221,11 @@ class _TextFieldState extends State { _editableTextKey.currentState?.requestKeyboard(); } + void _onSelectionChanged(BuildContext context, bool longPress) { + if (longPress) + Feedback.forLongPress(context); + } + @override Widget build(BuildContext context) { final ThemeData themeData = Theme.of(context); @@ -243,6 +249,7 @@ class _TextFieldState extends State { selectionControls: materialTextSelectionControls, onChanged: widget.onChanged, onSubmitted: widget.onSubmitted, + onSelectionChanged: (TextSelection _, bool longPress) => _onSelectionChanged(context, longPress), inputFormatters: widget.inputFormatters, ), ); diff --git a/packages/flutter/lib/src/material/time_picker.dart b/packages/flutter/lib/src/material/time_picker.dart index bb3b6ec99ae66..1e3a61cc66330 100644 --- a/packages/flutter/lib/src/material/time_picker.dart +++ b/packages/flutter/lib/src/material/time_picker.dart @@ -13,6 +13,7 @@ import 'button.dart'; import 'button_bar.dart'; import 'colors.dart'; import 'dialog.dart'; +import 'feedback.dart'; import 'flat_button.dart'; import 'theme.dart'; import 'typography.dart'; @@ -289,7 +290,7 @@ class _TimePickerHeader extends StatelessWidget { ); final Widget dayPeriodPicker = new GestureDetector( - onTap: _handleChangeDayPeriod, + onTap: Feedback.wrapForTap(_handleChangeDayPeriod, context), behavior: HitTestBehavior.opaque, child: new Column( mainAxisSize: MainAxisSize.min, @@ -302,12 +303,12 @@ class _TimePickerHeader extends StatelessWidget { ); final Widget hour = new GestureDetector( - onTap: () => _handleChangeMode(_TimePickerMode.hour), + onTap: Feedback.wrapForTap(() => _handleChangeMode(_TimePickerMode.hour), context), child: new Text(selectedTime.hourOfPeriodLabel, style: hourStyle), ); final Widget minute = new GestureDetector( - onTap: () => _handleChangeMode(_TimePickerMode.minute), + onTap: Feedback.wrapForTap(() => _handleChangeMode(_TimePickerMode.minute), context), child: new Text(selectedTime.minuteLabel, style: minuteStyle), ); diff --git a/packages/flutter/lib/src/material/tooltip.dart b/packages/flutter/lib/src/material/tooltip.dart index c2ffde573f790..d131d73cdc243 100644 --- a/packages/flutter/lib/src/material/tooltip.dart +++ b/packages/flutter/lib/src/material/tooltip.dart @@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; +import 'feedback.dart'; import 'theme.dart'; import 'theme_data.dart'; @@ -110,12 +111,15 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { _removeEntry(); } - void ensureTooltipVisible() { + /// Shows the tooltip if it is not already visible. + /// + /// Returns `false` when the tooltip was already visible. + bool ensureTooltipVisible() { if (_entry != null) { _timer?.cancel(); _timer = null; _controller.forward(); - return; // Already visible. + return false; // Already visible. } final RenderBox box = context.findRenderObject(); final Offset target = box.localToGlobal(box.size.center(Offset.zero)); @@ -138,6 +142,7 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { Overlay.of(context, debugRequiredFor: widget).insert(_entry); GestureBinding.instance.pointerRouter.addGlobalRoute(_handlePointerEvent); _controller.forward(); + return true; } void _removeEntry() { @@ -177,7 +182,11 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { assert(Overlay.of(context, debugRequiredFor: widget) != null); return new GestureDetector( behavior: HitTestBehavior.opaque, - onLongPress: ensureTooltipVisible, + onLongPress: () { + final bool tooltipCreated = ensureTooltipVisible(); + if (tooltipCreated) + Feedback.forLongPress(context); + }, excludeFromSemantics: true, child: new Semantics( label: widget.message, diff --git a/packages/flutter/lib/src/rendering/box.dart b/packages/flutter/lib/src/rendering/box.dart index 270820fd50812..fcdb7acf24e1f 100644 --- a/packages/flutter/lib/src/rendering/box.dart +++ b/packages/flutter/lib/src/rendering/box.dart @@ -572,7 +572,7 @@ class BoxHitTestEntry extends HitTestEntry { final Offset localPosition; @override - String toString() => '${target.runtimeType}#${target.hashCode}@$localPosition'; + String toString() => '${describeIdentity(target)}@$localPosition'; } /// Parent data used by [RenderBox] and its subclasses. diff --git a/packages/flutter/lib/src/rendering/error.dart b/packages/flutter/lib/src/rendering/error.dart index ddd0af811731f..7dfda9c001219 100644 --- a/packages/flutter/lib/src/rendering/error.dart +++ b/packages/flutter/lib/src/rendering/error.dart @@ -93,7 +93,7 @@ class RenderErrorBox extends RenderBox { /// The paragraph style to use when painting [RenderErrorBox] objects. static ui.ParagraphStyle paragraphStyle = new ui.ParagraphStyle( - lineHeight: 0.85 + lineHeight: 1.0, ); @override diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index 6e248e0600451..beb3adbcc7bcc 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -636,7 +636,7 @@ abstract class _SemanticsFragment { Iterable compile({ _SemanticsGeometry geometry, SemanticsNode currentSemantics, SemanticsNode parentSemantics }); @override - String toString() => '$runtimeType#$hashCode'; + String toString() => describeIdentity(this); } /// A SemanticsFragment that doesn't produce any [SemanticsNode]s when compiled. @@ -2686,7 +2686,7 @@ abstract class RenderObject extends AbstractNode implements HitTestTarget { /// Returns a human understandable name. @override String toString() { - String header = '$runtimeType#$hashCode'; + String header = describeIdentity(this); if (_relayoutBoundary != null && _relayoutBoundary != this) { int count = 1; RenderObject target = parent; diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index 4453c870bbfeb..38b5bb5011f27 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -2034,7 +2034,7 @@ abstract class CustomPainter extends Listenable { bool hitTest(Offset position) => null; @override - String toString() => '$runtimeType#$hashCode(${ _repaint?.toString() ?? "" })'; + String toString() => '${describeIdentity(this)}(${ _repaint?.toString() ?? "" })'; } /// Provides a canvas on which to draw during the paint phase. diff --git a/packages/flutter/lib/src/rendering/semantics.dart b/packages/flutter/lib/src/rendering/semantics.dart index cddd67779efbd..f2bdd7a9e682c 100644 --- a/packages/flutter/lib/src/rendering/semantics.dart +++ b/packages/flutter/lib/src/rendering/semantics.dart @@ -781,5 +781,5 @@ class SemanticsOwner extends ChangeNotifier { } @override - String toString() => '$runtimeType#$hashCode'; + String toString() => describeIdentity(this); } diff --git a/packages/flutter/lib/src/rendering/viewport_offset.dart b/packages/flutter/lib/src/rendering/viewport_offset.dart index 2062af0528902..6b1721e2562ba 100644 --- a/packages/flutter/lib/src/rendering/viewport_offset.dart +++ b/packages/flutter/lib/src/rendering/viewport_offset.dart @@ -170,7 +170,7 @@ abstract class ViewportOffset extends ChangeNotifier { String toString() { final List description = []; debugFillDescription(description); - return '$runtimeType#$hashCode(${description.join(", ")})'; + return '${describeIdentity(this)}(${description.join(", ")})'; } /// Add additional information to the given description for use by [toString]. diff --git a/packages/flutter/lib/src/scheduler/ticker.dart b/packages/flutter/lib/src/scheduler/ticker.dart index ff45d5203d65c..fce31cf165afe 100644 --- a/packages/flutter/lib/src/scheduler/ticker.dart +++ b/packages/flutter/lib/src/scheduler/ticker.dart @@ -419,7 +419,7 @@ class TickerFuture implements Future { } @override - String toString() => '$runtimeType#$hashCode(${ _completed == null ? "active" : _completed ? "complete" : "canceled" })'; + String toString() => '${describeIdentity(this)}(${ _completed == null ? "active" : _completed ? "complete" : "canceled" })'; } /// Exception thrown by [Ticker] objects on the [TickerFuture.orCancel] future diff --git a/packages/flutter/lib/src/services/asset_bundle.dart b/packages/flutter/lib/src/services/asset_bundle.dart index de3074c0bb071..3daff2b94c735 100644 --- a/packages/flutter/lib/src/services/asset_bundle.dart +++ b/packages/flutter/lib/src/services/asset_bundle.dart @@ -79,7 +79,7 @@ abstract class AssetBundle { void evict(String key) { } @override - String toString() => '$runtimeType#$hashCode()'; + String toString() => '${describeIdentity(this)}()'; } /// An [AssetBundle] that loads resources over the network. @@ -133,7 +133,7 @@ class NetworkAssetBundle extends AssetBundle { // should implement evict(). @override - String toString() => '$runtimeType#$hashCode($_baseUrl)'; + String toString() => '${describeIdentity(this)}($_baseUrl)'; } /// An [AssetBundle] that permanently caches string and structured resources diff --git a/packages/flutter/lib/src/services/image_provider.dart b/packages/flutter/lib/src/services/image_provider.dart index 6d4644fd93513..7306c69b5fe0c 100644 --- a/packages/flutter/lib/src/services/image_provider.dart +++ b/packages/flutter/lib/src/services/image_provider.dart @@ -161,7 +161,7 @@ class ImageConfiguration { /// /// The following shows the code required to write a widget that fully conforms /// to the [ImageProvider] and [Widget] protocols. (It is essentially a -/// bare-bones version of the [Image] widget.) +/// bare-bones version of the [widgets.Image] widget.) /// /// ```dart /// class MyImage extends StatefulWidget { @@ -528,6 +528,12 @@ class FileImage extends ImageProvider { /// Decodes the given [Uint8List] buffer as an image, associating it with the /// given scale. +/// +/// The provided [bytes] buffer should not be changed after it is provided +/// to a [MemoryImage]. To provide an [ImageStream] that represents an image +/// that changes over time, consider creating a new subclass of [ImageProvider] +/// whose [load] method returns a subclass of [ImageStreamCompleter] that can +/// handle providing multiple images. class MemoryImage extends ImageProvider { /// Creates an object that decodes a [Uint8List] buffer as an image. /// @@ -578,8 +584,9 @@ class MemoryImage extends ImageProvider { int get hashCode => hashValues(bytes.hashCode, scale); @override - String toString() => '$runtimeType(${bytes.runtimeType}#${bytes.hashCode}, scale: $scale)'; + String toString() => '$runtimeType(${describeIdentity(bytes)}, scale: $scale)'; } + /// Fetches an image from an [AssetBundle], associating it with the given scale. /// /// This implementation requires an explicit final [name] and [scale] on diff --git a/packages/flutter/lib/src/services/image_resolution.dart b/packages/flutter/lib/src/services/image_resolution.dart index 25f5da8d48f00..22cb6cf0a30c0 100644 --- a/packages/flutter/lib/src/services/image_resolution.dart +++ b/packages/flutter/lib/src/services/image_resolution.dart @@ -152,6 +152,7 @@ class AssetImage extends AssetBundleImageProvider { mapping[_parseScale(candidate)] = candidate; mapping[_naturalResolution] = main; // TODO(ianh): implement support for config.locale, config.size, config.platform + // (then document this over in the Image.asset docs) return _findNearest(mapping, config.devicePixelRatio); } @@ -177,7 +178,7 @@ class AssetImage extends AssetBundleImageProvider { final Match match = _extractRatioRegExp.firstMatch(key); if (match != null && match.groupCount > 0) return double.parse(match.group(1)); - return _naturalResolution; + return _naturalResolution; // i.e. default to 1.0x } @override diff --git a/packages/flutter/lib/src/services/image_stream.dart b/packages/flutter/lib/src/services/image_stream.dart index 55a3cdfee36ce..54a9f937f648b 100644 --- a/packages/flutter/lib/src/services/image_stream.dart +++ b/packages/flutter/lib/src/services/image_stream.dart @@ -88,6 +88,10 @@ class ImageStream { /// /// This is usually done automatically by the [ImageProvider] that created the /// [ImageStream]. + /// + /// This method can only be called once per stream. To have an [ImageStream] + /// represent multiple images over time, assign it a completer that + /// completes several images in succession. void setCompleter(ImageStreamCompleter value) { assert(_completer == null); _completer = value; @@ -98,9 +102,17 @@ class ImageStream { } } - /// Adds a listener callback that is called whenever a concrete [ImageInfo] + /// Adds a listener callback that is called whenever a new concrete [ImageInfo] /// object is available. If a concrete image is already available, this object /// will call the listener synchronously. + /// + /// If the assigned [completer] completes multiple images over its lifetime, + /// this listener will fire multiple times. + /// + /// The listener will be passed a flag indicating whether a synchronous call + /// occurred. If the listener is added within a render object paint function, + /// then use this flag to avoid calling [RenderObject.markNeedsPaint] during + /// a paint. void addListener(ImageListener listener) { if (_completer != null) return _completer.addListener(listener); @@ -161,13 +173,17 @@ class ImageStreamCompleter { final List _listeners = []; ImageInfo _current; - /// Adds a listener callback that is called whenever a concrete [ImageInfo] + /// Adds a listener callback that is called whenever a new concrete [ImageInfo] /// object is available. If a concrete image is already available, this object /// will call the listener synchronously. /// + /// If the assigned [completer] completes multiple images over its lifetime, + /// this listener will fire multiple times. + /// /// The listener will be passed a flag indicating whether a synchronous call /// occurred. If the listener is added within a render object paint function, - /// then use this flag to avoid calling markNeedsPaint during a paint. + /// then use this flag to avoid calling [RenderObject.markNeedsPaint] during + /// a paint. void addListener(ImageListener listener) { _listeners.add(listener); if (_current != null) { @@ -254,7 +270,8 @@ class OneFrameImageStreamCompleter extends ImageStreamCompleter { stack: stack, library: 'services', context: 'resolving a single-frame image stream', - informationCollector: informationCollector + informationCollector: informationCollector, + silent: true, )); }); } diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index 669716d9c136e..66faacb6b9151 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -38,9 +38,13 @@ typedef Future LocaleChangedCallback(Locale locale); class WidgetsApp extends StatefulWidget { /// Creates a widget that wraps a number of widgets that are commonly /// required for an application. + /// + /// The boolean arguments, [color], [navigatorObservers], and + /// [onGenerateRoute] must not be null. const WidgetsApp({ Key key, @required this.onGenerateRoute, + this.onUnknownRoute, this.title, this.textStyle, @required this.color, @@ -52,12 +56,14 @@ class WidgetsApp extends StatefulWidget { this.checkerboardOffscreenLayers: false, this.showSemanticsDebugger: false, this.debugShowCheckedModeBanner: true - }) : assert(color != null), - assert(onGenerateRoute != null), + }) : assert(onGenerateRoute != null), + assert(color != null), + assert(navigatorObservers != null), assert(showPerformanceOverlay != null), assert(checkerboardRasterCacheImages != null), assert(checkerboardOffscreenLayers != null), assert(showSemanticsDebugger != null), + assert(debugShowCheckedModeBanner != null), super(key: key); /// A one-line description of this app for use in the window manager. @@ -75,11 +81,46 @@ class WidgetsApp extends StatefulWidget { /// The route generator callback used when the app is navigated to a /// named route. + /// + /// If this returns null when building the routes to handle the specified + /// [initialRoute], then all the routes are discarded and + /// [Navigator.defaultRouteName] is used instead (`/`). See [initialRoute]. + /// + /// During normal app operation, the [onGenerateRoute] callback will only be + /// applied to route names pushed by the application, and so should never + /// return null. final RouteFactory onGenerateRoute; + /// Called when [onGenerateRoute] fails to generate a route. + /// + /// This callback is typically used for error handling. For example, this + /// callback might always generate a "not found" page that describes the route + /// that wasn't found. + /// + /// Unknown routes can arise either from errors in the app or from external + /// requests to push routes, such as from Android intents. + final RouteFactory onUnknownRoute; + /// The name of the first route to show. /// - /// Defaults to [Window.defaultRouteName]. + /// Defaults to [Window.defaultRouteName], which may be overridden by the code + /// that launched the application. + /// + /// If the route contains slashes, then it is treated as a "deep link", and + /// before this route is pushed, the routes leading to this one are pushed + /// also. For example, if the route was `/a/b/c`, then the app would start + /// with the three routes `/a`, `/a/b`, and `/a/b/c` loaded, in that order. + /// + /// If any part of this process fails to generate routes, then the + /// [initialRoute] is ignored and [Navigator.defaultRouteName] is used instead + /// (`/`). This can happen if the app is started with an intent that specifies + /// a non-existent route. + /// + /// See also: + /// + /// * [Navigator.initialRoute], which is used to implement this property. + /// * [Navigator.push], for pushing additional routes. + /// * [Navigator.pop], for removing a route from the stack. final String initialRoute; /// Callback that is called when the operating system changes the @@ -221,6 +262,7 @@ class _WidgetsAppState extends State implements WidgetsBindingObserv key: _navigator, initialRoute: widget.initialRoute ?? ui.window.defaultRouteName, onGenerateRoute: widget.onGenerateRoute, + onUnknownRoute: widget.onUnknownRoute, observers: widget.navigatorObservers ) ) @@ -238,13 +280,13 @@ class _WidgetsAppState extends State implements WidgetsBindingObserv // options are set. if (widget.showPerformanceOverlay || WidgetsApp.showPerformanceOverlayOverride) { performanceOverlay = new PerformanceOverlay.allEnabled( - checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages, - checkerboardOffscreenLayers: widget.checkerboardOffscreenLayers, + checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages, + checkerboardOffscreenLayers: widget.checkerboardOffscreenLayers, ); } else if (widget.checkerboardRasterCacheImages || widget.checkerboardOffscreenLayers) { performanceOverlay = new PerformanceOverlay( - checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages, - checkerboardOffscreenLayers: widget.checkerboardOffscreenLayers, + checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages, + checkerboardOffscreenLayers: widget.checkerboardOffscreenLayers, ); } diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 6444b413d9f65..372a845a635e5 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -12,7 +12,11 @@ import 'debug.dart'; import 'framework.dart'; export 'package:flutter/animation.dart'; -export 'package:flutter/foundation.dart' show TargetPlatform; +export 'package:flutter/foundation.dart' show + ChangeNotifier, + Listenable, + TargetPlatform, + ValueNotifier; export 'package:flutter/painting.dart'; export 'package:flutter/rendering.dart' show Axis, diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 7ac573f28d76c..7b9702eb6265b 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -21,6 +21,10 @@ import 'text_selection.dart'; export 'package:flutter/services.dart' show TextEditingValue, TextSelection, TextInputType; +/// Signature for the callback that reports when the user changes the selection +/// (including the cursor location). +typedef void SelectionChangedCallback(TextSelection selection, bool longPress); + const Duration _kCursorBlinkHalfPeriod = const Duration(milliseconds: 500); /// A controller for an editable text field. @@ -150,6 +154,7 @@ class EditableText extends StatefulWidget { this.keyboardType, this.onChanged, this.onSubmitted, + this.onSelectionChanged, List inputFormatters, }) : assert(controller != null), assert(focusNode != null), @@ -226,6 +231,10 @@ class EditableText extends StatefulWidget { /// Called when the user indicates that they are done editing the text in the field. final ValueChanged onSubmitted; + /// Called when the user changes the selection of text (including the cursor + /// location). + final SelectionChangedCallback onSelectionChanged; + /// Optional input validation and formatting overrides. Formatters are run /// in the provided order when the text input changes. final List inputFormatters; @@ -447,6 +456,8 @@ class EditableTextState extends State implements TextInputClient { _selectionOverlay.showHandles(); if (longPress) _selectionOverlay.showToolbar(); + if (widget.onSelectionChanged != null) + widget.onSelectionChanged(selection, longPress); } } @@ -531,6 +542,10 @@ class EditableTextState extends State implements TextInputClient { _openOrCloseInputConnectionIfNeeded(); _startOrStopCursorTimerIfNeeded(); _updateOrDisposeSelectionOverlayIfNeeded(); + if (!_hasFocus) { + // Clear the selection and composition state if this widget lost focus. + _value = new TextEditingValue(text: _value.text); + } } @override diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart index f1529079e3b27..8090fa51253a4 100644 --- a/packages/flutter/lib/src/widgets/focus_manager.dart +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -23,6 +23,16 @@ import 'package:flutter/foundation.dart'; /// method to reparent your [FocusNode] if your widget moves from one /// location in the tree to another. /// +/// ## Lifetime +/// +/// Focus nodes are long-lived objects. For example, if a stateful widget has a +/// focusable child widget, it should create a [FocusNode] in the +/// [State.initState] method, and [dispose] it in the [State.dispose] method, +/// providing the same [FocusNode] to the focusable child each time the +/// [State.build] method is run. In particular, creating a [FocusNode] each time +/// [State.build] is invoked will cause the focus to be lost each time the +/// widget is built. +/// /// See also: /// /// * [FocusScopeNode], which is an interior node in the focus tree. @@ -94,7 +104,7 @@ class FocusNode extends ChangeNotifier { } @override - String toString() => '$runtimeType#$hashCode${hasFocus ? '(FOCUSED)' : ''}'; + String toString() => '${describeIdentity(this)}${hasFocus ? '(FOCUSED)' : ''}'; } /// An interior node in the focus tree. @@ -446,7 +456,7 @@ class FocusManager { String toString() { final String status = _haveScheduledUpdate ? ' UPDATE SCHEDULED' : ''; final String indent = ' '; - return '$runtimeType#$hashCode$status\n' + return '${describeIdentity(this)}$status\n' '${indent}currentFocus: $_currentFocus\n' '${rootScope.toStringDeep(indent, indent)}'; } diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index 2a64d1adda025..38ffdd6c90179 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -112,7 +112,7 @@ class UniqueKey extends LocalKey { UniqueKey(); @override - String toString() => '[#$hashCode]'; + String toString() => '[#${shortHash(this)}]'; } /// A key that takes its identity from the object used as its value. @@ -142,8 +142,8 @@ class ObjectKey extends LocalKey { @override String toString() { if (runtimeType == ObjectKey) - return '[${value.runtimeType}#${value.hashCode}]'; - return '[$runtimeType ${value.runtimeType}#${value.hashCode}]'; + return '[${describeIdentity(value)}]'; + return '[$runtimeType ${describeIdentity(value)}]'; } } @@ -333,8 +333,8 @@ class LabeledGlobalKey> extends GlobalKey { String toString() { final String label = _debugLabel != null ? ' $_debugLabel' : ''; if (runtimeType == LabeledGlobalKey) - return '[GlobalKey#$hashCode$label]'; - return '[$runtimeType#$hashCode$label]'; + return '[GlobalKey#${shortHash(this)}$label]'; + return '[${describeIdentity(this)}$label]'; } } @@ -364,7 +364,7 @@ class GlobalObjectKey> extends GlobalKey { int get hashCode => identityHashCode(value); @override - String toString() => '[$runtimeType ${value.runtimeType}#${value.hashCode}]'; + String toString() => '[$runtimeType ${describeIdentity(value)}]'; } /// This class is a work-around for the "is" operator not accepting a variable value as its right operand @@ -1247,7 +1247,7 @@ abstract class State { String toString() { final List data = []; debugFillDescription(data); - return '$runtimeType#$hashCode(${data.join("; ")})'; + return '${describeIdentity(this)}(${data.join("; ")})'; } /// Add additional information to the given description for use by [toString]. diff --git a/packages/flutter/lib/src/widgets/image.dart b/packages/flutter/lib/src/widgets/image.dart index ee4011fa44bff..b6889f5684837 100644 --- a/packages/flutter/lib/src/widgets/image.dart +++ b/packages/flutter/lib/src/widgets/image.dart @@ -133,13 +133,51 @@ class Image extends StatefulWidget { /// If the `bundle` argument is omitted or null, then the /// [DefaultAssetBundle] will be used. /// - /// If the `scale` argument is omitted or null, then pixel-density-aware asset - /// resolution will be attempted. + /// By default, the exact asset specified will be used. In addition: /// - /// If [width] and [height] are both specified, and [scale] is not, then - /// size-aware asset resolution will be attempted also. + /// * If the `scale` argument is omitted or null, then pixel-density-aware + /// asset resolution will be attempted. + // + // TODO(ianh): Implement the following (see ../services/image_resolution.dart): + // /// + // /// * If [width] and [height] are both specified, and [scale] is not, then + // /// size-aware asset resolution will be attempted also, with the given + // /// dimensions interpreted as logical pixels. + // /// + // /// * If the images have platform or locale variants, the current platform + // /// and locale is taken into account during asset resolution as well. /// /// The [name] and [repeat] arguments must not be null. + /// + /// ## Sample code + /// + /// Suppose that the project's `pubspec.yaml` file contains the following: + /// + /// ```yaml + /// flutter: + /// assets: + /// - images/cat.png + /// - images/2x/cat.png + /// - images/3.5x/cat.png + /// ``` + /// + /// On a screen with a device pixel ratio of 2.0, the following widget would + /// render the `images/2x/cat.png` file: + /// + /// ```dart + /// new Image.asset('images/cat.png') + /// ``` + /// + /// This corresponds to the file that is in the project's `images/2x/` + /// directory with the name `cat.png` (the paths are relative to the + /// `pubspec.yaml` file). + /// + /// See also: + /// + /// * [AssetImage], which is used to implement the behavior when the scale is + /// omitted. + /// * [ExactAssetImage], which is used to implement the behavior when the + /// scale is present. Image.asset(String name, { Key key, AssetBundle bundle, diff --git a/packages/flutter/lib/src/widgets/implicit_animations.dart b/packages/flutter/lib/src/widgets/implicit_animations.dart index 4e74a904e3272..5aa13f8d47533 100644 --- a/packages/flutter/lib/src/widgets/implicit_animations.dart +++ b/packages/flutter/lib/src/widgets/implicit_animations.dart @@ -111,12 +111,21 @@ class Matrix4Tween extends Tween { Matrix4 lerp(double t) { assert(begin != null); assert(end != null); - // TODO(abarth): We should use [Matrix4.decompose] and animate the - // decomposed parameters instead of just animating the translation. - final Vector3 beginT = begin.getTranslation(); - final Vector3 endT = end.getTranslation(); - final Vector3 lerpT = beginT*(1.0-t) + endT*t; - return new Matrix4.identity()..translate(lerpT); + final Vector3 beginTranslation = new Vector3.zero(); + final Vector3 endTranslation = new Vector3.zero(); + final Quaternion beginRotation = new Quaternion.identity(); + final Quaternion endRotation = new Quaternion.identity(); + final Vector3 beginScale = new Vector3.zero(); + final Vector3 endScale = new Vector3.zero(); + begin.decompose(beginTranslation, beginRotation, beginScale); + end.decompose(endTranslation, endRotation, endScale); + final Vector3 lerpTranslation = + beginTranslation * (1.0 - t) + endTranslation * t; + // TODO(alangardner): Implement slerp for constant rotation + final Quaternion lerpRotation = + (beginRotation.scaled(1.0 - t) + endRotation.scaled(t)).normalized(); + final Vector3 lerpScale = beginScale * (1.0 - t) + endScale * t; + return new Matrix4.compose(lerpTranslation, lerpRotation, lerpScale); } } diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart index 94ab3b5ebdc84..6cc8e4499f6f3 100644 --- a/packages/flutter/lib/src/widgets/navigator.dart +++ b/packages/flutter/lib/src/widgets/navigator.dart @@ -207,6 +207,18 @@ class RouteSettings { this.isInitialRoute: false, }); + /// Creates a copy of this route settings object with the given fields + /// replaced with the new values. + RouteSettings copyWith({ + String name, + bool isInitialRoute, + }) { + return new RouteSettings( + name: name ?? this.name, + isInitialRoute: isInitialRoute ?? this.isInitialRoute, + ); + } + /// The name of the route (e.g., "/settings"). /// /// If null, the route is anonymous. @@ -374,7 +386,7 @@ typedef bool RoutePredicate(Route route); /// The app's home page route is named '/' by default. /// /// The [MaterialApp] can be created -/// with a `Map` which maps from a route's name to +/// with a [Map] which maps from a route's name to /// a builder function that will create it. The [MaterialApp] uses this /// map to create a value for its navigator's [onGenerateRoute] callback. /// @@ -496,6 +508,17 @@ class Navigator extends StatefulWidget { super(key: key); /// The name of the first route to show. + /// + /// By default, this defers to [dart:ui.Window.defaultRouteName]. + /// + /// If this string contains any `/` characters, then the string is split on + /// those characters and substrings from the start of the string up to each + /// such character are, in turn, used as routes to push. + /// + /// For example, if the route `/stocks/HOOLI` was used as the [initialRoute], + /// then the [Navigator] would push the following routes on startup: `/`, + /// `/stocks`, `/stocks/HOOLI`. This enables deep linking while allowing the + /// application to maintain a predictable route history. final String initialRoute; /// Called to generate a route for a given [RouteSettings]. @@ -514,7 +537,12 @@ class Navigator extends StatefulWidget { /// A list of observers for this navigator. final List observers; - /// The default name for the initial route. + /// The default name for the [initialRoute]. + /// + /// See also: + /// + /// * [dart:ui.Window.defaultRouteName], which reflects the route that the + /// application was started with. static const String defaultRouteName = '/'; /// Push a named route onto the navigator that most tightly encloses the given context. @@ -730,6 +758,8 @@ class NavigatorState extends State with TickerProviderStateMixin { /// The [FocusScopeNode] for the [FocusScope] that encloses the routes. final FocusScopeNode focusScopeNode = new FocusScopeNode(); + final List _initialOverlayEntries = []; + @override void initState() { super.initState(); @@ -737,10 +767,57 @@ class NavigatorState extends State with TickerProviderStateMixin { assert(observer.navigator == null); observer._navigator = this; } - push(widget.onGenerateRoute(new RouteSettings( - name: widget.initialRoute ?? Navigator.defaultRouteName, - isInitialRoute: true - ))); + String initialRouteName = widget.initialRoute ?? Navigator.defaultRouteName; + if (initialRouteName.startsWith('/') && initialRouteName.length > 1) { + initialRouteName = initialRouteName.substring(1); // strip leading '/' + assert(Navigator.defaultRouteName == '/'); + final List plannedInitialRouteNames = [ + Navigator.defaultRouteName, + ]; + final List> plannedInitialRoutes = >[ + _routeNamed(Navigator.defaultRouteName, allowNull: true), + ]; + final List routeParts = initialRouteName.split('/'); + if (initialRouteName.isNotEmpty) { + String routeName = ''; + for (String part in routeParts) { + routeName += '/$part'; + plannedInitialRouteNames.add(routeName); + plannedInitialRoutes.add(_routeNamed(routeName, allowNull: true)); + } + } + if (plannedInitialRoutes.contains(null)) { + assert(() { + FlutterError.reportError( + new FlutterErrorDetails( // ignore: prefer_const_constructors, https://github.com/dart-lang/sdk/issues/29952 + exception: + 'Could not navigate to initial route.\n' + 'The requested route name was: "/$initialRouteName"\n' + 'The following routes were therefore attempted:\n' + ' * ${plannedInitialRouteNames.join("\n * ")}\n' + 'This resulted in the following objects:\n' + ' * ${plannedInitialRoutes.join("\n * ")}\n' + 'One or more of those objects was null, and therefore the initial route specified will be ' + 'ignored and "${Navigator.defaultRouteName}" will be used instead.' + ), + ); + return true; + }); + push(_routeNamed(Navigator.defaultRouteName)); + } else { + for (Route route in plannedInitialRoutes) + push(route); + } + } else { + Route route; + if (initialRouteName != Navigator.defaultRouteName) + route = _routeNamed(initialRouteName, allowNull: true); + if (route == null) + route = _routeNamed(Navigator.defaultRouteName); + push(route); + } + for (Route route in _history) + _initialOverlayEntries.addAll(route.overlayEntries); } @override @@ -785,15 +862,40 @@ class NavigatorState extends State with TickerProviderStateMixin { bool _debugLocked = false; // used to prevent re-entrant calls to push, pop, and friends - Route _routeNamed(String name) { + Route _routeNamed(String name, { bool allowNull: false }) { assert(!_debugLocked); assert(name != null); - final RouteSettings settings = new RouteSettings(name: name); + final RouteSettings settings = new RouteSettings( + name: name, + isInitialRoute: _history.isEmpty, + ); Route route = widget.onGenerateRoute(settings); - if (route == null) { - assert(widget.onUnknownRoute != null); + if (route == null && !allowNull) { + assert(() { + if (widget.onUnknownRoute == null) { + throw new FlutterError( + 'If a Navigator has no onUnknownRoute, then its onGenerateRoute must never return null.\n' + 'When trying to build the route "$name", onGenerateRoute returned null, but there was no ' + 'onUnknownRoute callback specified.\n' + 'The Navigator was:\n' + ' $this' + ); + } + return true; + }); route = widget.onUnknownRoute(settings); - assert(route != null); + assert(() { + if (route == null) { + throw new FlutterError( + 'A Navigator\'s onUnknownRoute returned null.\n' + 'When trying to build the route "$name", both onGenerateRoute and onUnknownRoute returned ' + 'null. The onUnknownRoute callback should never return null.\n' + 'The Navigator was:\n' + ' $this' + ); + } + return true; + }); } return route; } @@ -1245,7 +1347,6 @@ class NavigatorState extends State with TickerProviderStateMixin { Widget build(BuildContext context) { assert(!_debugLocked); assert(_history.isNotEmpty); - final Route initialRoute = _history.first; return new Listener( onPointerDown: _handlePointerDown, onPointerUp: _handlePointerUpOrCancel, @@ -1257,7 +1358,7 @@ class NavigatorState extends State with TickerProviderStateMixin { autofocus: true, child: new Overlay( key: _overlayKey, - initialEntries: initialRoute.overlayEntries, + initialEntries: _initialOverlayEntries, ), ), ), diff --git a/packages/flutter/lib/src/widgets/overlay.dart b/packages/flutter/lib/src/widgets/overlay.dart index e8465c5024a7a..54e5978afa0b7 100644 --- a/packages/flutter/lib/src/widgets/overlay.dart +++ b/packages/flutter/lib/src/widgets/overlay.dart @@ -150,7 +150,7 @@ class OverlayEntry { } @override - String toString() => '$runtimeType#$hashCode(opaque: $opaque; maintainState: $maintainState)'; + String toString() => '${describeIdentity(this)}(opaque: $opaque; maintainState: $maintainState)'; } class _OverlayEntry extends StatefulWidget { diff --git a/packages/flutter/lib/src/widgets/scroll_activity.dart b/packages/flutter/lib/src/widgets/scroll_activity.dart index 596e6ed79039c..4cad08f31004f 100644 --- a/packages/flutter/lib/src/widgets/scroll_activity.dart +++ b/packages/flutter/lib/src/widgets/scroll_activity.dart @@ -125,7 +125,7 @@ abstract class ScrollActivity { } @override - String toString() => '$runtimeType#$hashCode'; + String toString() => describeIdentity(this); } /// A scroll activity that does nothing. @@ -282,9 +282,7 @@ class ScrollDragController implements Drag { dynamic _lastDetails; @override - String toString() { - return '$runtimeType#$hashCode'; - } + String toString() => describeIdentity(this); } /// The activity a scroll view performs when a the user drags their finger @@ -350,7 +348,7 @@ class DragScrollActivity extends ScrollActivity { @override String toString() { - return '$runtimeType#$hashCode($_controller)'; + return '${describeIdentity(this)}($_controller)'; } } @@ -441,7 +439,7 @@ class BallisticScrollActivity extends ScrollActivity { @override String toString() { - return '$runtimeType#$hashCode($_controller)'; + return '${describeIdentity(this)}($_controller)'; } } @@ -526,6 +524,6 @@ class DrivenScrollActivity extends ScrollActivity { @override String toString() { - return '$runtimeType#$hashCode($_controller)'; + return '${describeIdentity(this)}($_controller)'; } } diff --git a/packages/flutter/lib/src/widgets/scroll_controller.dart b/packages/flutter/lib/src/widgets/scroll_controller.dart index aeb1dea48a0b8..b96c5de4e5373 100644 --- a/packages/flutter/lib/src/widgets/scroll_controller.dart +++ b/packages/flutter/lib/src/widgets/scroll_controller.dart @@ -238,7 +238,7 @@ class ScrollController extends ChangeNotifier { String toString() { final List description = []; debugFillDescription(description); - return '$runtimeType#$hashCode(${description.join(", ")})'; + return '${describeIdentity(this)}(${description.join(", ")})'; } /// Add additional information to the given description for use by [toString]. diff --git a/packages/flutter/lib/src/widgets/scroll_view.dart b/packages/flutter/lib/src/widgets/scroll_view.dart index 8ff1896254c5b..f403cbaaa2301 100644 --- a/packages/flutter/lib/src/widgets/scroll_view.dart +++ b/packages/flutter/lib/src/widgets/scroll_view.dart @@ -420,6 +420,40 @@ abstract class BoxScrollView extends ScrollView { /// ) /// ``` /// +/// ## Transitioning to [CustomScrollView] +/// +/// A [ListView] is basically a [CustomScrollView] with a single [SliverList] in +/// its [slivers] property. +/// +/// If [ListView] is no longer sufficient, for example because the scroll view +/// is to have both a list and a grid, or because the list is to be combined +/// with a [SliverAppBar], etc, it is straight-forward to port code from using +/// [ListView] to using [CustomScrollView] directly. +/// +/// The [key], [scrollDirection], [reverse], [controller], [primary], [physics], +/// and [shrinkWrap] properties on [ListView] map directly to the identically +/// named properties on [CustomScrollView]. +/// +/// The [CustomScrollView.slivers] property should be a list containing either a +/// [SliverList] or a [SliverFixedExtentList]; the former if [itemExtent] on the +/// [ListView] was null, and the latter if [itemExtent] was not null. +/// +/// The [childrenDelegate] property on [ListView] corresponds to the +/// [SliverList.delegate] (or [SliverFixedExtentList.delegate]) property. The +/// [new ListView] constructor's `children` argument corresponds to the +/// [childrenDelegate] being a [SliverChildListDelegate] with that same +/// argument. The [new ListView.builder] constructor's `itemBuilder` and +/// `childCount` arguments correspond to the [childrenDelegate] being a +/// [SliverChildBuilderDelegate] with the matching arguments. +/// +/// The [padding] property corresponds to having a [SliverPadding] in the +/// [CustomScrollView.slivers] property instead of the list itself, and having +/// the [SliverList] instead be a child of the [SliverPadding]. +/// +/// Once code has been ported to use [CustomScrollView], other slivers, such as +/// [SliverGrid] or [SliverAppBar], can be put in the [CustomScrollView.slivers] +/// list. +/// /// See also: /// /// * [SingleChildScrollView], which is a scrollable widget that has a single @@ -438,6 +472,9 @@ class ListView extends BoxScrollView { /// children because constructing the [List] requires doing work for every /// child that could possibly be displayed in the list view instead of just /// those children that are actually visible. + /// + /// It is usually more efficient to create children on demand using [new + /// ListView.builder]. ListView({ Key key, Axis scrollDirection: Axis.vertical, @@ -466,11 +503,18 @@ class ListView extends BoxScrollView { /// number of children because the builder is called only for those children /// that are actually visible. /// - /// Providing a non-null [itemCount] improves the ability of the [ListView] to + /// Providing a non-null `itemCount` improves the ability of the [ListView] to /// estimate the maximum scroll extent. /// - /// [itemBuilder] will be called only with indices greater than or equal to - /// zero and less than [itemCount]. + /// The `itemBuilder` callback will be called only with indices greater than + /// or equal to zero and less than `itemCount`. + /// + /// The `itemBuilder` should actually create the widget instances when called. + /// Avoid using a builder that returns a previously-constructed widget; if the + /// list view's children are created in advance, or all at once when the + /// [ListView] itself is created, it is more efficient to use [new ListView]. + /// Even more efficient, however, is to create the instances on demand using + /// this constructor's `itemBuilder` callback. ListView.builder({ Key key, Axis scrollDirection: Axis.vertical, @@ -575,6 +619,47 @@ class ListView extends BoxScrollView { /// /// To create a linear array of children, use a [ListView]. /// +/// ## Transitioning to [CustomScrollView] +/// +/// A [GridView] is basically a [CustomScrollView] with a single [SliverGrid] in +/// its [slivers] property. +/// +/// If [GridView] is no longer sufficient, for example because the scroll view +/// is to have both a grid and a list, or because the grid is to be combined +/// with a [SliverAppBar], etc, it is straight-forward to port code from using +/// [GridView] to using [CustomScrollView] directly. +/// +/// The [key], [scrollDirection], [reverse], [controller], [primary], [physics], +/// and [shrinkWrap] properties on [GridView] map directly to the identically +/// named properties on [CustomScrollView]. +/// +/// The [CustomScrollView.slivers] property should be a list containing just a +/// [SliverGrid]. +/// +/// The [childrenDelegate] property on [GridView] corresponds to the +/// [SliverGrid.delegate] property, and the [gridDelegate] property on the +/// [GridView] corresponds to the [SliverGrid.gridDelegate] property. +/// +/// The [new GridView], [new GridView.count], and [new GridView.extent] +/// constructors' `children` arguments correspond to the [childrenDelegate] +/// being a [SliverChildListDelegate] with that same argument. The [new +/// GridView.builder] constructor's `itemBuilder` and `childCount` arguments +/// correspond to the [childrenDelegate] being a [SliverChildBuilderDelegate] +/// with the matching arguments. +/// +/// The [new GridView.count] and [new GridView.extent] constructors create +/// custom grid delegates, and have equivalently named constructors on +/// [SliverGrid] to ease the transition: [new SliverGrid.count] and [new +/// SliverGrid.extent] respectively. +/// +/// The [padding] property corresponds to having a [SliverPadding] in the +/// [CustomScrollView.slivers] property instead of the grid itself, and having +/// the [SliverGrid] instead be a child of the [SliverPadding]. +/// +/// Once code has been ported to use [CustomScrollView], other slivers, such as +/// [SliverGrid] or [SliverAppBar], can be put in the [CustomScrollView.slivers] +/// list. +/// /// See also: /// /// * [SingleChildScrollView], which is a scrollable widget that has a single @@ -623,11 +708,11 @@ class GridView extends BoxScrollView { /// number of children because the builder is called only for those children /// that are actually visible. /// - /// Providing a non-null [itemCount] improves the ability of the [GridView] to + /// Providing a non-null `itemCount` improves the ability of the [GridView] to /// estimate the maximum scroll extent. /// - /// [itemBuilder] will be called only with indices greater than or equal to - /// zero and less than [itemCount]. + /// `itemBuilder` will be called only with indices greater than or equal to + /// zero and less than `itemCount`. /// /// The [gridDelegate] argument must not be null. GridView.builder({ @@ -690,6 +775,10 @@ class GridView extends BoxScrollView { /// the cross axis. /// /// Uses a [SliverGridDelegateWithFixedCrossAxisCount] as the [gridDelegate]. + /// + /// See also: + /// + /// * [new SliverGrid.count], the equivalent constructor for [SliverGrid]. GridView.count({ Key key, Axis scrollDirection: Axis.vertical, @@ -725,6 +814,10 @@ class GridView extends BoxScrollView { /// cross-axis extent. /// /// Uses a [SliverGridDelegateWithMaxCrossAxisExtent] as the [gridDelegate]. + /// + /// See also: + /// + /// * [new SliverGrid.extent], the equivalent constructor for [SliverGrid]. GridView.extent({ Key key, Axis scrollDirection: Axis.vertical, diff --git a/packages/flutter/lib/src/widgets/sliver.dart b/packages/flutter/lib/src/widgets/sliver.dart index b5db8f704ceff..b206b3dcd3a77 100644 --- a/packages/flutter/lib/src/widgets/sliver.dart +++ b/packages/flutter/lib/src/widgets/sliver.dart @@ -94,7 +94,7 @@ abstract class SliverChildDelegate { String toString() { final List description = []; debugFillDescription(description); - return '$runtimeType#$hashCode(${description.join(", ")})'; + return '${describeIdentity(this)}(${description.join(", ")})'; } /// Add additional information to the given description for use by [toString]. @@ -436,6 +436,46 @@ class SliverGrid extends SliverMultiBoxAdaptorWidget { @required this.gridDelegate, }) : super(key: key, delegate: delegate); + /// Creates a sliver that places multiple box children in a two dimensional + /// arrangement with a fixed number of tiles in the cross axis. + /// + /// Uses a [SliverGridDelegateWithFixedCrossAxisCount] as the [gridDelegate], + /// and a [SliverChildListDelegate] as the [delegate]. + SliverGrid.count({ + Key key, + @required int crossAxisCount, + double mainAxisSpacing: 0.0, + double crossAxisSpacing: 0.0, + double childAspectRatio: 1.0, + List children: const [], + }) : gridDelegate = new SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: mainAxisSpacing, + crossAxisSpacing: crossAxisSpacing, + childAspectRatio: childAspectRatio, + ), + super(key: key, delegate: new SliverChildListDelegate(children)); + + /// Creates a sliver that places multiple box children in a two dimensional + /// arrangement with tiles that have a maximum cross-axis extent. + /// + /// Uses a [SliverGridDelegateWithMaxCrossAxisExtent] as the [gridDelegate], + /// and a [SliverChildListDelegate] as the [delegate]. + SliverGrid.extent({ + Key key, + @required double maxCrossAxisExtent, + double mainAxisSpacing: 0.0, + double crossAxisSpacing: 0.0, + double childAspectRatio: 1.0, + List children: const [], + }) : gridDelegate = new SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: maxCrossAxisExtent, + mainAxisSpacing: mainAxisSpacing, + crossAxisSpacing: crossAxisSpacing, + childAspectRatio: childAspectRatio, + ), + super(key: key, delegate: new SliverChildListDelegate(children)); + /// The delegate that controls the size and position of the children. final SliverGridDelegate gridDelegate; @@ -537,13 +577,20 @@ class SliverMultiBoxAdaptorElement extends RenderObjectElement implements Render performRebuild(); } - final SplayTreeMap _childElements = new SplayTreeMap(); + // We inflate widgets at two different times: + // 1. When we ourselves are told to rebuild (see performRebuild). + // 2. When our render object needs a new child (see createChild). + // In both cases, we cache the results of calling into our delegate to get the widget, + // so that if we do case 2 later, we don't call the builder again. + // Any time we do case 1, though, we reset the cache. + final Map _childWidgets = new HashMap(); + final SplayTreeMap _childElements = new SplayTreeMap(); RenderBox _currentBeforeChild; @override void performRebuild() { - _childWidgets.clear(); + _childWidgets.clear(); // Reset the cache, as described above. super.performRebuild(); _currentBeforeChild = null; assert(_currentlyUpdatingChildIndex == null); @@ -556,9 +603,6 @@ class SliverMultiBoxAdaptorElement extends RenderObjectElement implements Render } else if (_didUnderflow) { lastIndex += 1; } - // We won't call the delegate's build function multiple times, because we - // cache the delegate's results until the next time we need to rebuild the - // whole widget. for (int index = firstIndex; index <= lastIndex; ++index) { _currentlyUpdatingChildIndex = index; final Element newChild = updateChild(_childElements[index], _build(index), index); diff --git a/packages/flutter/pubspec.yaml b/packages/flutter/pubspec.yaml index 54df2b4f84bcf..ada12fa1bbcf0 100644 --- a/packages/flutter/pubspec.yaml +++ b/packages/flutter/pubspec.yaml @@ -1,5 +1,5 @@ name: flutter -version: 0.0.31-dev +version: 0.0.32-dev author: Flutter Authors description: A framework for writing Flutter applications homepage: http://flutter.io diff --git a/packages/flutter/test/animation/tween_test.dart b/packages/flutter/test/animation/tween_test.dart index e8a9a97ff7f30..72364f4232845 100644 --- a/packages/flutter/test/animation/tween_test.dart +++ b/packages/flutter/test/animation/tween_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/animation.dart'; import 'package:flutter/widgets.dart'; +import 'package:vector_math/vector_math_64.dart'; void main() { test('Can chain tweens', () { @@ -49,4 +50,26 @@ void main() { expect(tween.lerp(0.5), equals(Rect.lerp(a, b, 0.5))); expect(tween, hasOneLineDescription); }); + + test('Matrix4Tween', () { + final Matrix4 a = new Matrix4.identity(); + final Matrix4 b = a.clone()..translate(6.0, -8.0, 0.0)..scale(0.5, 1.0, 5.0); + final Matrix4Tween tween = new Matrix4Tween(begin: a, end: b); + expect(tween.lerp(0.0), equals(a)); + expect(tween.lerp(1.0), equals(b)); + expect( + tween.lerp(0.5), + equals(a.clone()..translate(3.0, -4.0, 0.0)..scale(0.75, 1.0, 3.0)) + ); + final Matrix4 c = a.clone()..rotateZ(1.0); + final Matrix4Tween rotationTween = new Matrix4Tween(begin: a, end: c); + expect(rotationTween.lerp(0.0), equals(a)); + expect(rotationTween.lerp(1.0), equals(c)); + expect( + rotationTween.lerp(0.5).absoluteError( + a.clone()..rotateZ(0.5) + ), + moreOrLessEquals(0.0) + ); + }); } diff --git a/packages/flutter/test/foundation/service_extensions_test.dart b/packages/flutter/test/foundation/service_extensions_test.dart index f0bfc9b4650a1..e5ea2fdb054ac 100644 --- a/packages/flutter/test/foundation/service_extensions_test.dart +++ b/packages/flutter/test/foundation/service_extensions_test.dart @@ -143,7 +143,7 @@ void main() { expect(console, [ matches( r'^' - r'RenderView#[0-9]+\n' + r'RenderView#[0-9a-f]{5}\n' r' debug mode enabled - [a-zA-Z]+\n' r' window size: Size\(2400\.0, 1800\.0\) \(in physical pixels\)\n' r' device pixel ratio: 3\.0 \(physical pixels per logical pixel\)\n' @@ -163,8 +163,8 @@ void main() { expect(console, [ matches( r'^' - r'TransformLayer#[0-9]+\n' - r' owner: RenderView#[0-9]+\n' + r'TransformLayer#[0-9a-f]{5}\n' + r' owner: RenderView#[0-9a-f]{5}\n' r' creator: RenderView\n' r' offset: Offset\(0\.0, 0\.0\)\n' r' transform:\n' diff --git a/packages/flutter/test/foundation/tree_diagnostics_mixin_test.dart b/packages/flutter/test/foundation/tree_diagnostics_mixin_test.dart index 1d4593c4856f8..7f5a5758bf214 100644 --- a/packages/flutter/test/foundation/tree_diagnostics_mixin_test.dart +++ b/packages/flutter/test/foundation/tree_diagnostics_mixin_test.dart @@ -41,7 +41,7 @@ void main() { ); final String dump = - tree.toStringDeep().replaceAll(new RegExp(r'#\d+'), '#000'); + tree.toStringDeep().replaceAll(new RegExp(r'#[0-9a-z]{5}'), '#000'); expect(dump, equals('''TestTree#000 ├─child node A: TestTree#000 │ diff --git a/packages/flutter/test/material/app_test.dart b/packages/flutter/test/material/app_test.dart index 92249a5ae1244..470e4973c89ed 100644 --- a/packages/flutter/test/material/app_test.dart +++ b/packages/flutter/test/material/app_test.dart @@ -152,34 +152,65 @@ void main() { expect(find.text('route "/"'), findsOneWidget); }); - testWidgets('Custom initialRoute only', (WidgetTester tester) async { + testWidgets('One-step initial route', (WidgetTester tester) async { await tester.pumpWidget( new MaterialApp( initialRoute: '/a', routes: { + '/': (BuildContext context) => const Text('route "/"'), '/a': (BuildContext context) => const Text('route "/a"'), + '/a/b': (BuildContext context) => const Text('route "/a/b"'), + '/b': (BuildContext context) => const Text('route "/b"'), }, ) ); + expect(find.text('route "/"'), findsOneWidget); expect(find.text('route "/a"'), findsOneWidget); + expect(find.text('route "/a/b"'), findsNothing); + expect(find.text('route "/b"'), findsNothing); }); - testWidgets('Custom initialRoute along with Navigator.defaultRouteName', (WidgetTester tester) async { + testWidgets('Two-step initial route', (WidgetTester tester) async { final Map routes = { '/': (BuildContext context) => const Text('route "/"'), '/a': (BuildContext context) => const Text('route "/a"'), + '/a/b': (BuildContext context) => const Text('route "/a/b"'), '/b': (BuildContext context) => const Text('route "/b"'), }; await tester.pumpWidget( new MaterialApp( - initialRoute: '/a', + initialRoute: '/a/b', routes: routes, ) ); - expect(find.text('route "/"'), findsNothing); + expect(find.text('route "/"'), findsOneWidget); expect(find.text('route "/a"'), findsOneWidget); + expect(find.text('route "/a/b"'), findsOneWidget); + expect(find.text('route "/b"'), findsNothing); + }); + + testWidgets('Initial route with missing step', (WidgetTester tester) async { + final Map routes = { + '/': (BuildContext context) => const Text('route "/"'), + '/a': (BuildContext context) => const Text('route "/a"'), + '/a/b': (BuildContext context) => const Text('route "/a/b"'), + '/b': (BuildContext context) => const Text('route "/b"'), + }; + + await tester.pumpWidget( + new MaterialApp( + initialRoute: '/a/b/c', + routes: routes, + ) + ); + final dynamic exception = tester.takeException(); + expect(exception is String, isTrue); + expect(exception.startsWith('Could not navigate to initial route.'), isTrue); + expect(find.text('route "/"'), findsOneWidget); + expect(find.text('route "/a"'), findsNothing); + expect(find.text('route "/a/b"'), findsNothing); expect(find.text('route "/b"'), findsNothing); }); @@ -196,23 +227,41 @@ void main() { routes: routes, ) ); - expect(find.text('route "/"'), findsNothing); + expect(find.text('route "/"'), findsOneWidget); expect(find.text('route "/a"'), findsOneWidget); expect(find.text('route "/b"'), findsNothing); + // changing initialRoute has no effect await tester.pumpWidget( new MaterialApp( initialRoute: '/b', routes: routes, ) ); - expect(find.text('route "/"'), findsNothing); + expect(find.text('route "/"'), findsOneWidget); expect(find.text('route "/a"'), findsOneWidget); expect(find.text('route "/b"'), findsNothing); + // removing it has no effect await tester.pumpWidget(new MaterialApp(routes: routes)); - expect(find.text('route "/"'), findsNothing); + expect(find.text('route "/"'), findsOneWidget); expect(find.text('route "/a"'), findsOneWidget); expect(find.text('route "/b"'), findsNothing); }); + + testWidgets('onGenerateRoute / onUnknownRoute', (WidgetTester tester) async { + final List log = []; + await tester.pumpWidget( + new MaterialApp( + onGenerateRoute: (RouteSettings settings) { + log.add('onGenerateRoute ${settings.name}'); + }, + onUnknownRoute: (RouteSettings settings) { + log.add('onUnknownRoute ${settings.name}'); + }, + ) + ); + expect(tester.takeException(), isFlutterError); + expect(log, ['onGenerateRoute /', 'onUnknownRoute /']); + }); } diff --git a/packages/flutter/test/material/chip_test.dart b/packages/flutter/test/material/chip_test.dart index 402c86afc911b..4b862de54d92e 100644 --- a/packages/flutter/test/material/chip_test.dart +++ b/packages/flutter/test/material/chip_test.dart @@ -5,8 +5,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'feedback_tester.dart'; + void main() { testWidgets('Chip control test', (WidgetTester tester) async { + final FeedbackTester feedback = new FeedbackTester(); bool didDeleteChip = false; await tester.pumpWidget( new MaterialApp( @@ -26,8 +29,15 @@ void main() { ) ); + expect(feedback.clickSoundCount, 0); + expect(didDeleteChip, isFalse); await tester.tap(find.byType(Tooltip)); expect(didDeleteChip, isTrue); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 1); + + feedback.dispose(); }); } diff --git a/packages/flutter/test/material/date_picker_test.dart b/packages/flutter/test/material/date_picker_test.dart index a50d0c9334bdd..a161d5995e93d 100644 --- a/packages/flutter/test/material/date_picker_test.dart +++ b/packages/flutter/test/material/date_picker_test.dart @@ -3,10 +3,11 @@ // found in the LICENSE file. import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:intl/intl.dart'; +import 'feedback_tester.dart'; + void main() { DateTime firstDate; DateTime lastDate; @@ -273,34 +274,31 @@ void main() { group('haptic feedback', () { const Duration kHapticFeedbackInterval = const Duration(milliseconds: 10); - int hapticFeedbackCount; - - setUpAll(() { - SystemChannels.platform.setMockMethodCallHandler((MethodCall methodCall) async { - if (methodCall.method == "HapticFeedback.vibrate") - hapticFeedbackCount++; - }); - }); + FeedbackTester feedback; setUp(() { - hapticFeedbackCount = 0; + feedback = new FeedbackTester(); initialDate = new DateTime(2017, DateTime.JANUARY, 16); firstDate = new DateTime(2017, DateTime.JANUARY, 10); lastDate = new DateTime(2018, DateTime.JANUARY, 20); selectableDayPredicate = (DateTime date) => date.day.isEven; }); + tearDown(() { + feedback?.dispose(); + }); + testWidgets('tap-select date vibrates', (WidgetTester tester) async { await preparePicker(tester, (Future date) async { await tester.tap(find.text('10')); await tester.pump(kHapticFeedbackInterval); - expect(hapticFeedbackCount, 1); + expect(feedback.hapticCount, 1); await tester.tap(find.text('12')); await tester.pump(kHapticFeedbackInterval); - expect(hapticFeedbackCount, 2); + expect(feedback.hapticCount, 2); await tester.tap(find.text('14')); await tester.pump(kHapticFeedbackInterval); - expect(hapticFeedbackCount, 3); + expect(feedback.hapticCount, 3); }); }); @@ -308,13 +306,13 @@ void main() { await preparePicker(tester, (Future date) async { await tester.tap(find.text('11')); await tester.pump(kHapticFeedbackInterval); - expect(hapticFeedbackCount, 0); + expect(feedback.hapticCount, 0); await tester.tap(find.text('13')); await tester.pump(kHapticFeedbackInterval); - expect(hapticFeedbackCount, 0); + expect(feedback.hapticCount, 0); await tester.tap(find.text('15')); await tester.pump(kHapticFeedbackInterval); - expect(hapticFeedbackCount, 0); + expect(feedback.hapticCount, 0); }); }); @@ -322,10 +320,10 @@ void main() { await preparePicker(tester, (Future date) async { await tester.tap(find.text('2017')); await tester.pump(kHapticFeedbackInterval); - expect(hapticFeedbackCount, 1); + expect(feedback.hapticCount, 1); await tester.tap(find.text('2018')); await tester.pump(kHapticFeedbackInterval); - expect(hapticFeedbackCount, 2); + expect(feedback.hapticCount, 2); }); }); }); diff --git a/packages/flutter/test/material/feedback_test.dart b/packages/flutter/test/material/feedback_test.dart new file mode 100644 index 0000000000000..b8676d58e26b1 --- /dev/null +++ b/packages/flutter/test/material/feedback_test.dart @@ -0,0 +1,162 @@ +// Copyright 2017 The Chromium 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'; +import 'package:flutter_test/flutter_test.dart'; + +import 'feedback_tester.dart'; + +void main () { + const Duration kWaitDuration = const Duration(seconds: 1); + + FeedbackTester feedback; + + setUp(() { + feedback = new FeedbackTester(); + }); + + tearDown(() { + feedback?.dispose(); + }); + + group('Feedback on Android', () { + + testWidgets('forTap', (WidgetTester tester) async { + await tester.pumpWidget(new TestWidget( + tapHandler: (BuildContext context) { + return () => Feedback.forTap(context); + }, + )); + await tester.pumpAndSettle(kWaitDuration); + expect(feedback.hapticCount, 0); + expect(feedback.clickSoundCount, 0); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(kWaitDuration); + expect(feedback.hapticCount, 0); + expect(feedback.clickSoundCount, 1); + }); + + testWidgets('forTap Wrapper', (WidgetTester tester) async { + int callbackCount = 0; + final VoidCallback callback = () { + callbackCount++; + }; + + await tester.pumpWidget(new TestWidget( + tapHandler: (BuildContext context) { + return Feedback.wrapForTap(callback, context); + }, + )); + await tester.pumpAndSettle(kWaitDuration); + expect(feedback.hapticCount, 0); + expect(feedback.clickSoundCount, 0); + expect(callbackCount, 0); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(kWaitDuration); + expect(feedback.hapticCount, 0); + expect(feedback.clickSoundCount, 1); + expect(callbackCount, 1); + }); + + testWidgets('forLongPress', (WidgetTester tester) async { + await tester.pumpWidget(new TestWidget( + longPressHandler: (BuildContext context) { + return () => Feedback.forLongPress(context); + }, + )); + await tester.pumpAndSettle(kWaitDuration); + expect(feedback.hapticCount, 0); + expect(feedback.clickSoundCount, 0); + + await tester.longPress(find.text('X')); + await tester.pumpAndSettle(kWaitDuration); + expect(feedback.hapticCount, 1); + expect(feedback.clickSoundCount, 0); + }); + + testWidgets('forLongPress Wrapper', (WidgetTester tester) async { + int callbackCount = 0; + final VoidCallback callback = () { + callbackCount++; + }; + + await tester.pumpWidget(new TestWidget( + longPressHandler: (BuildContext context) { + return Feedback.wrapForLongPress(callback, context); + }, + )); + await tester.pumpAndSettle(kWaitDuration); + expect(feedback.hapticCount, 0); + expect(feedback.clickSoundCount, 0); + expect(callbackCount, 0); + + await tester.longPress(find.text('X')); + await tester.pumpAndSettle(kWaitDuration); + expect(feedback.hapticCount, 1); + expect(feedback.clickSoundCount, 0); + expect(callbackCount, 1); + }); + + }); + + group('Feedback on iOS', () { + testWidgets('forTap', (WidgetTester tester) async { + await tester.pumpWidget(new Theme( + data: new ThemeData(platform: TargetPlatform.iOS), + child: new TestWidget( + tapHandler: (BuildContext context) { + return () => Feedback.forTap(context); + }, + ), + )); + + await tester.tap(find.text('X')); + await tester.pumpAndSettle(kWaitDuration); + expect(feedback.hapticCount, 0); + expect(feedback.clickSoundCount, 0); + }); + + testWidgets('forLongPress', (WidgetTester tester) async { + await tester.pumpWidget(new Theme( + data: new ThemeData(platform: TargetPlatform.iOS), + child: new TestWidget( + longPressHandler: (BuildContext context) { + return () => Feedback.forLongPress(context); + }, + ), + )); + + await tester.longPress(find.text('X')); + await tester.pumpAndSettle(kWaitDuration); + expect(feedback.hapticCount, 0); + expect(feedback.clickSoundCount, 0); + }); + }); +} + +class TestWidget extends StatelessWidget { + + TestWidget({ + this.tapHandler: nullHandler, + this.longPressHandler: nullHandler, + }); + + final HandlerCreator tapHandler; + final HandlerCreator longPressHandler; + + static VoidCallback nullHandler(BuildContext context) => null; + + @override + Widget build(BuildContext context) { + return new GestureDetector( + onTap: tapHandler(context), + onLongPress: longPressHandler(context), + child: const Text('X'), + ); + } +} + +typedef VoidCallback HandlerCreator(BuildContext context); diff --git a/packages/flutter/test/material/feedback_tester.dart b/packages/flutter/test/material/feedback_tester.dart new file mode 100644 index 0000000000000..e952b74d74580 --- /dev/null +++ b/packages/flutter/test/material/feedback_tester.dart @@ -0,0 +1,34 @@ +// Copyright 2017 The Chromium 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/services.dart'; + +/// Tracks how often feedback has been requested since its instantiation. +/// +/// It replaces the MockMethodCallHandler of [SystemChannels.platform] and +/// cannot be used in combination with other classes that do the same. +class FeedbackTester { + FeedbackTester() { + SystemChannels.platform.setMockMethodCallHandler((MethodCall methodCall) { + if (methodCall.method == "HapticFeedback.vibrate") + _hapticCount++; + if (methodCall.method == "SystemSound.play" && + methodCall.arguments == SystemSoundType.click.toString()) + _clickSoundCount++; + }); + } + + /// Number of times haptic feedback was requested (vibration). + int get hapticCount => _hapticCount; + int _hapticCount = 0; + + /// Number of times the click sound was requested to play. + int get clickSoundCount => _clickSoundCount; + int _clickSoundCount = 0; + + /// Stops tracking. + void dispose() { + SystemChannels.platform.setMockMethodCallHandler(null); + } +} \ No newline at end of file diff --git a/packages/flutter/test/material/ink_well_test.dart b/packages/flutter/test/material/ink_well_test.dart index 82764d4911a4b..323c428814dbf 100644 --- a/packages/flutter/test/material/ink_well_test.dart +++ b/packages/flutter/test/material/ink_well_test.dart @@ -5,6 +5,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'feedback_tester.dart'; + void main() { testWidgets('InkWell gestures control test', (WidgetTester tester) async { final List log = []; @@ -44,4 +46,74 @@ void main() { expect(log, equals(['long-press'])); }); + + testWidgets('long-press and tap on disabled should not throw', (WidgetTester tester) async { + await tester.pumpWidget(const Material( + child: const Center( + child: const InkWell(), + ), + )); + await tester.tap(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + await tester.longPress(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + }); + + group('feedback', () { + FeedbackTester feedback; + + setUp(() { + feedback = new FeedbackTester(); + }); + + tearDown(() { + feedback?.dispose(); + }); + + testWidgets('enabled (default)', (WidgetTester tester) async { + await tester.pumpWidget(new Material( + child: new Center( + child: new InkWell( + onTap: () {}, + onLongPress: () {}, + ), + ), + )); + await tester.tap(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 1); + expect(feedback.hapticCount, 0); + + await tester.tap(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 2); + expect(feedback.hapticCount, 0); + + await tester.longPress(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 2); + expect(feedback.hapticCount, 1); + }); + + testWidgets('disabled', (WidgetTester tester) async { + await tester.pumpWidget(new Material( + child: new Center( + child: new InkWell( + onTap: () {}, + onLongPress: () {}, + enableFeedback: false, + ), + ), + )); + await tester.tap(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + + await tester.longPress(find.byType(InkWell), pointer: 1); + await tester.pump(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + }); + }); } diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 530e546e4bf84..38daea7fa2b4a 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -9,6 +9,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; +import 'feedback_tester.dart'; + class MockClipboard { Object _clipboardData = { 'text': null, @@ -1503,4 +1505,73 @@ void main() { expect(scrollableState.position.pixels, isNot(equals(0.0))); } ); + + testWidgets('haptic feedback', (WidgetTester tester) async { + final FeedbackTester feedback = new FeedbackTester(); + final TextEditingController controller = new TextEditingController(); + + Widget builder() { + return overlay(new Center( + child: new Material( + child: new Container( + width: 100.0, + child: new TextField( + controller: controller, + ), + ), + ), + )); + } + + await tester.pumpWidget(builder()); + + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 0); + + await tester.longPress(find.byType(TextField)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(feedback.clickSoundCount, 0); + expect(feedback.hapticCount, 1); + + feedback.dispose(); + }); + + testWidgets( + 'Text field drops selection when losing focus', + (WidgetTester tester) async { + final Key key1 = new UniqueKey(); + final TextEditingController controller1 = new TextEditingController(); + final Key key2 = new UniqueKey(); + + Widget builder() { + return overlay(new Center( + child: new Material( + child: new Column( + children: [ + new TextField( + key: key1, + controller: controller1 + ), + new TextField(key: key2), + ], + ), + ), + )); + } + + await tester.pumpWidget(builder()); + await tester.tap(find.byKey(key1)); + await tester.enterText(find.byKey(key1), 'abcd'); + await tester.pump(); + controller1.selection = const TextSelection(baseOffset: 0, extentOffset: 3); + await tester.pump(); + expect(controller1.selection, isNot(equals(TextRange.empty))); + + await tester.tap(find.byKey(key2)); + await tester.pump(); + expect(controller1.selection, equals(TextRange.empty)); + } + ); } diff --git a/packages/flutter/test/material/time_picker_test.dart b/packages/flutter/test/material/time_picker_test.dart index 90bde69f1f598..bb7f77158a141 100644 --- a/packages/flutter/test/material/time_picker_test.dart +++ b/packages/flutter/test/material/time_picker_test.dart @@ -3,9 +3,10 @@ // found in the LICENSE file. import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'feedback_tester.dart'; + class _TimePickerLauncher extends StatelessWidget { const _TimePickerLauncher({ Key key, this.onChanged }) : super(key: key); @@ -115,24 +116,21 @@ void main() { group('haptic feedback', () { const Duration kFastFeedbackInterval = const Duration(milliseconds: 10); const Duration kSlowFeedbackInterval = const Duration(milliseconds: 200); - int hapticFeedbackCount; + FeedbackTester feedback; - setUpAll(() { - SystemChannels.platform.setMockMethodCallHandler((MethodCall methodCall) { - if (methodCall.method == "HapticFeedback.vibrate") - hapticFeedbackCount++; - }); + setUp(() { + feedback = new FeedbackTester(); }); - setUp(() { - hapticFeedbackCount = 0; + tearDown(() { + feedback?.dispose(); }); testWidgets('tap-select vibrates once', (WidgetTester tester) async { final Offset center = await startPicker(tester, (TimeOfDay time) { }); await tester.tapAt(new Offset(center.dx, center.dy - 50.0)); await finishPicker(tester); - expect(hapticFeedbackCount, 1); + expect(feedback.hapticCount, 1); }); testWidgets('quick successive tap-selects vibrate once', (WidgetTester tester) async { @@ -141,7 +139,7 @@ void main() { await tester.pump(kFastFeedbackInterval); await tester.tapAt(new Offset(center.dx, center.dy + 50.0)); await finishPicker(tester); - expect(hapticFeedbackCount, 1); + expect(feedback.hapticCount, 1); }); testWidgets('slow successive tap-selects vibrate once per tap', (WidgetTester tester) async { @@ -152,7 +150,7 @@ void main() { await tester.pump(kSlowFeedbackInterval); await tester.tapAt(new Offset(center.dx, center.dy - 50.0)); await finishPicker(tester); - expect(hapticFeedbackCount, 3); + expect(feedback.hapticCount, 3); }); testWidgets('drag-select vibrates once', (WidgetTester tester) async { @@ -164,7 +162,7 @@ void main() { await gesture.moveBy(hour0 - hour3); await gesture.up(); await finishPicker(tester); - expect(hapticFeedbackCount, 1); + expect(feedback.hapticCount, 1); }); testWidgets('quick drag-select vibrates once', (WidgetTester tester) async { @@ -180,7 +178,7 @@ void main() { await gesture.moveBy(hour0 - hour3); await gesture.up(); await finishPicker(tester); - expect(hapticFeedbackCount, 1); + expect(feedback.hapticCount, 1); }); testWidgets('slow drag-select vibrates once', (WidgetTester tester) async { @@ -196,7 +194,7 @@ void main() { await gesture.moveBy(hour0 - hour3); await gesture.up(); await finishPicker(tester); - expect(hapticFeedbackCount, 3); + expect(feedback.hapticCount, 3); }); }); } diff --git a/packages/flutter/test/material/tooltip_test.dart b/packages/flutter/test/material/tooltip_test.dart index d23fdac56c1b4..f592e81880274 100644 --- a/packages/flutter/test/material/tooltip_test.dart +++ b/packages/flutter/test/material/tooltip_test.dart @@ -9,6 +9,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import '../widgets/semantics_tester.dart'; +import 'feedback_tester.dart'; // This file uses "as dynamic" in a few places to defeat the static // analysis. In general you want to avoid using this style in your @@ -501,4 +502,27 @@ void main() { expect(find.text(tooltipText), findsNothing); }); + testWidgets('Haptic feedback', (WidgetTester tester) async { + final FeedbackTester feedback = new FeedbackTester(); + await tester.pumpWidget(new MaterialApp( + home: new Center( + child: new Tooltip( + message: 'Foo', + child: new Container( + width: 100.0, + height: 100.0, + color: Colors.green[500], + ) + ) + ) + ) + ); + + await tester.longPress(find.byType(Tooltip)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(feedback.hapticCount, 1); + + feedback.dispose(); + }); + } diff --git a/packages/flutter/test/painting/decoration_test.dart b/packages/flutter/test/painting/decoration_test.dart index d1dc57381f8ac..2e587a45064f7 100644 --- a/packages/flutter/test/painting/decoration_test.dart +++ b/packages/flutter/test/painting/decoration_test.dart @@ -75,7 +75,7 @@ class DelayedImageProvider extends ImageProvider { } @override - String toString() => '$runtimeType#$hashCode()'; + String toString() => '${describeIdentity(this)}}()'; } class TestImage extends ui.Image { diff --git a/packages/flutter/test/widgets/framework_test.dart b/packages/flutter/test/widgets/framework_test.dart index d8c3f6b6823cb..e4e2ab71f3247 100644 --- a/packages/flutter/test/widgets/framework_test.dart +++ b/packages/flutter/test/widgets/framework_test.dart @@ -483,7 +483,7 @@ void main() { final MultiChildRenderObjectElement element = key0.currentContext; final String dump = - element.toStringDeep().replaceAll(new RegExp(r'#\d+'), '#000'); + element.toStringDeep().replaceAll(new RegExp(r'#[0-9a-f]{5}'), '#000'); expect(dump, equals('''Column([GlobalKey#000]; renderObject: RenderFlex#000) ├Container() │└LimitedBox(maxWidth: 0.0; maxHeight: 0.0; renderObject: RenderLimitedBox#000 relayoutBoundary=up1) diff --git a/packages/flutter/test/widgets/global_keys_duplicated_test.dart b/packages/flutter/test/widgets/global_keys_duplicated_test.dart index 7d774a0ee1b53..1cea63b9a0ac1 100644 --- a/packages/flutter/test/widgets/global_keys_duplicated_test.dart +++ b/packages/flutter/test/widgets/global_keys_duplicated_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/widgets.dart'; @@ -19,7 +20,7 @@ void main() { expect(error, isFlutterError); expect(error.toString(), startsWith('Duplicate keys found.\n')); expect(error.toString(), contains('Row')); - expect(error.toString(), contains('[GlobalObjectKey int#${0.hashCode}]')); + expect(error.toString(), contains('[GlobalObjectKey ${describeIdentity(0)}]')); }); testWidgets('GlobalKey children of two nodes', (WidgetTester tester) async { @@ -32,7 +33,7 @@ void main() { expect(error.toString(), startsWith('Multiple widgets used the same GlobalKey.\n')); expect(error.toString(), contains('different widgets that both had the following description')); expect(error.toString(), contains('Container')); - expect(error.toString(), contains('[GlobalObjectKey int#${0.hashCode}]')); + expect(error.toString(), contains('[GlobalObjectKey ${describeIdentity(0)}]')); expect(error.toString(), endsWith('\nA GlobalKey can only be specified on one widget at a time in the widget tree.')); }); @@ -47,7 +48,7 @@ void main() { expect(error.toString(), isNot(contains('different widgets that both had the following description'))); expect(error.toString(), contains('Container()')); expect(error.toString(), contains('Container([<\'x\'>])')); - expect(error.toString(), contains('[GlobalObjectKey int#${0.hashCode}]')); + expect(error.toString(), contains('[GlobalObjectKey ${describeIdentity(0)}]')); expect(error.toString(), endsWith('\nA GlobalKey can only be specified on one widget at a time in the widget tree.')); }); @@ -73,7 +74,7 @@ void main() { // The following line is verifying the grammar is correct in this common case. // We should probably also verify the three other combinations that can be generated... expect(error.toString(), contains('This was determined by noticing that after the widget with the above global key was moved out of its previous parent, that previous parent never updated during this frame, meaning that it either did not update at all or updated before the widget was moved, in either case implying that it still thinks that it should have a child with that global key.')); - expect(error.toString(), contains('[GlobalObjectKey int#0]')); + expect(error.toString(), contains('[GlobalObjectKey ${describeIdentity(0)}]')); expect(error.toString(), contains('Container()')); expect(error.toString(), endsWith('\nA GlobalKey can only be specified on one widget at a time in the widget tree.')); expect(error, isFlutterError); diff --git a/packages/flutter/test/widgets/image_resolution_test.dart b/packages/flutter/test/widgets/image_resolution_test.dart index 03ee3f8b2043e..1048f85c091d0 100644 --- a/packages/flutter/test/widgets/image_resolution_test.dart +++ b/packages/flutter/test/widgets/image_resolution_test.dart @@ -77,7 +77,7 @@ class TestAssetBundle extends CachingAssetBundle { } @override - String toString() => '$runtimeType#$hashCode()'; + String toString() => '${describeIdentity(this)}()'; } class TestAssetImage extends AssetImage { diff --git a/packages/flutter/test/widgets/image_test.dart b/packages/flutter/test/widgets/image_test.dart index d513eacba17b3..ddc193bdefb41 100644 --- a/packages/flutter/test/widgets/image_test.dart +++ b/packages/flutter/test/widgets/image_test.dart @@ -294,12 +294,12 @@ void main() { final TestImageProvider imageProvider = new TestImageProvider(); await tester.pumpWidget(new Image(image: imageProvider)); final State image = tester.state/*State*/(find.byType(Image)); - expect(image.toString(), matches(new RegExp(r'_ImageState#[0-9]+\(stream: ImageStream\(OneFrameImageStreamCompleter; unresolved; 1 listener\); pixels: null\)'))); + expect(image.toString(), matches(new RegExp(r'_ImageState#[0-9a-f]{5}\(stream: ImageStream\(OneFrameImageStreamCompleter; unresolved; 1 listener\); pixels: null\)'))); imageProvider.complete(); await tester.pump(); - expect(image.toString(), matches(new RegExp(r'_ImageState#[0-9]+\(stream: ImageStream\(OneFrameImageStreamCompleter; \[100×100\] @ 1\.0x; 1 listener\); pixels: \[100×100\] @ 1\.0x\)'))); + expect(image.toString(), matches(new RegExp(r'_ImageState#[0-9a-f]{5}\(stream: ImageStream\(OneFrameImageStreamCompleter; \[100×100\] @ 1\.0x; 1 listener\); pixels: \[100×100\] @ 1\.0x\)'))); await tester.pumpWidget(new Container()); - expect(image.toString(), matches(new RegExp(r'_ImageState#[0-9]+\(_StateLifecycle.defunct; not mounted; stream: ImageStream\(OneFrameImageStreamCompleter; \[100×100\] @ 1\.0x; 0 listeners\); pixels: \[100×100\] @ 1\.0x\)'))); + expect(image.toString(), matches(new RegExp(r'_ImageState#[0-9a-f]{5}\(_StateLifecycle.defunct; not mounted; stream: ImageStream\(OneFrameImageStreamCompleter; \[100×100\] @ 1\.0x; 0 listeners\); pixels: \[100×100\] @ 1\.0x\)'))); }); testWidgets('Image.memory control test', (WidgetTester tester) async { @@ -343,7 +343,7 @@ class TestImageProvider extends ImageProvider { } @override - String toString() => '$runtimeType#$hashCode()'; + String toString() => '${describeIdentity(this)}()'; } class TestImage extends ui.Image { diff --git a/packages/flutter/test/widgets/routes_test.dart b/packages/flutter/test/widgets/routes_test.dart index bda498bd3416f..4ff567d07637a 100644 --- a/packages/flutter/test/widgets/routes_test.dart +++ b/packages/flutter/test/widgets/routes_test.dart @@ -98,6 +98,12 @@ void main() { testWidgets('Route settings', (WidgetTester tester) async { final RouteSettings settings = const RouteSettings(name: 'A'); expect(settings, hasOneLineDescription); + final RouteSettings settings2 = settings.copyWith(name: 'B'); + expect(settings2.name, 'B'); + expect(settings2.isInitialRoute, false); + final RouteSettings settings3 = settings2.copyWith(isInitialRoute: true); + expect(settings3.name, 'B'); + expect(settings3.isInitialRoute, true); }); testWidgets('Route management - push, replace, pop', (WidgetTester tester) async { diff --git a/packages/flutter/test/widgets/table_test.dart b/packages/flutter/test/widgets/table_test.dart index defb96a62ea73..daaf0c8b28052 100644 --- a/packages/flutter/test/widgets/table_test.dart +++ b/packages/flutter/test/widgets/table_test.dart @@ -524,7 +524,7 @@ void main() { final RenderObjectElement element = key0.currentContext; final String dump = - element.toStringDeep().replaceAll(new RegExp(r'#\d+'), '#000'); + element.toStringDeep().replaceAll(new RegExp(r'#[0-9a-f]{5}'), '#000'); expect(dump, equals('''Table([GlobalKey#000]; renderObject: RenderTable#000) ├Text("A") │└RichText(renderObject: RenderParagraph#000 relayoutBoundary=up1) diff --git a/packages/flutter_driver/lib/driver_extension.dart b/packages/flutter_driver/lib/driver_extension.dart index 15d3eae3f9a88..caac00edadea9 100644 --- a/packages/flutter_driver/lib/driver_extension.dart +++ b/packages/flutter_driver/lib/driver_extension.dart @@ -24,4 +24,4 @@ /// } library flutter_driver_extension; -export 'src/extension.dart' show enableFlutterDriverExtension; +export 'src/extension.dart' show enableFlutterDriverExtension, DataHandler; diff --git a/packages/flutter_driver/lib/src/driver.dart b/packages/flutter_driver/lib/src/driver.dart index edd18518bff5b..c3ed994873d17 100644 --- a/packages/flutter_driver/lib/src/driver.dart +++ b/packages/flutter_driver/lib/src/driver.dart @@ -21,6 +21,7 @@ import 'gesture.dart'; import 'health.dart'; import 'message.dart'; import 'render_tree.dart'; +import 'request_data.dart'; import 'semantics.dart'; import 'timeline.dart'; @@ -384,6 +385,13 @@ class FlutterDriver { return GetTextResult.fromJson(await _sendCommand(new GetText(finder, timeout: timeout))).text; } + /// Sends a string and returns a string. + /// + /// The application can respond to this by providing a handler to [enableFlutterDriverExtension]. + Future requestData(String message, { Duration timeout }) async { + return RequestDataResult.fromJson(await _sendCommand(new RequestData(message, timeout: timeout))).message; + } + /// Turns semantics on or off in the Flutter app under test. /// /// Returns `true` when the call actually changed the state from on to off or diff --git a/packages/flutter_driver/lib/src/extension.dart b/packages/flutter_driver/lib/src/extension.dart index cb5ff5fcc9f36..6a617cc57b4df 100644 --- a/packages/flutter_driver/lib/src/extension.dart +++ b/packages/flutter_driver/lib/src/extension.dart @@ -6,10 +6,12 @@ import 'dart:async'; import 'package:meta/meta.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show RendererBinding, SemanticsHandle; import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -20,19 +22,26 @@ import 'gesture.dart'; import 'health.dart'; import 'message.dart'; import 'render_tree.dart'; +import 'request_data.dart'; import 'semantics.dart'; const String _extensionMethodName = 'driver'; const String _extensionMethod = 'ext.flutter.$_extensionMethodName'; -class _DriverBinding extends WidgetsFlutterBinding { // TODO(ianh): refactor so we're not extending a concrete binding +typedef Future DataHandler(String message); + +class _DriverBinding extends BindingBase with SchedulerBinding, GestureBinding, ServicesBinding, RendererBinding, WidgetsBinding { + _DriverBinding(this._handler); + + final DataHandler _handler; + @override void initServiceExtensions() { super.initServiceExtensions(); - final FlutterDriverExtension extension = new FlutterDriverExtension(); + final FlutterDriverExtension extension = new FlutterDriverExtension(_handler); registerServiceExtension( name: _extensionMethodName, - callback: extension.call + callback: extension.call, ); } } @@ -44,9 +53,12 @@ class _DriverBinding extends WidgetsFlutterBinding { // TODO(ianh): refactor so /// /// Call this function prior to running your application, e.g. before you call /// `runApp`. -void enableFlutterDriverExtension() { +/// +/// Optionally you can pass a [DataHandler] callback. It will be called if the +/// test calls [FlutterDriver.requestData]. +void enableFlutterDriverExtension({ DataHandler handler }) { assert(WidgetsBinding.instance == null); - new _DriverBinding(); + new _DriverBinding(handler); assert(WidgetsBinding.instance is _DriverBinding); } @@ -62,18 +74,17 @@ typedef Finder FinderConstructor(SerializableFinder finder); @visibleForTesting class FlutterDriverExtension { - static final Logger _log = new Logger('FlutterDriverExtension'); - - FlutterDriverExtension() { + FlutterDriverExtension(this._requestDataHandler) { _commandHandlers.addAll({ 'get_health': _getHealth, 'get_render_tree': _getRenderTree, - 'tap': _tap, 'get_text': _getText, - 'set_frame_sync': _setFrameSync, - 'set_semantics': _setSemantics, + 'request_data': _requestData, 'scroll': _scroll, 'scrollIntoView': _scrollIntoView, + 'set_frame_sync': _setFrameSync, + 'set_semantics': _setSemantics, + 'tap': _tap, 'waitFor': _waitFor, 'waitUntilNoTransientCallbacks': _waitUntilNoTransientCallbacks, }); @@ -81,12 +92,13 @@ class FlutterDriverExtension { _commandDeserializers.addAll({ 'get_health': (Map params) => new GetHealth.deserialize(params), 'get_render_tree': (Map params) => new GetRenderTree.deserialize(params), - 'tap': (Map params) => new Tap.deserialize(params), 'get_text': (Map params) => new GetText.deserialize(params), - 'set_frame_sync': (Map params) => new SetFrameSync.deserialize(params), - 'set_semantics': (Map params) => new SetSemantics.deserialize(params), + 'request_data': (Map params) => new RequestData.deserialize(params), 'scroll': (Map params) => new Scroll.deserialize(params), 'scrollIntoView': (Map params) => new ScrollIntoView.deserialize(params), + 'set_frame_sync': (Map params) => new SetFrameSync.deserialize(params), + 'set_semantics': (Map params) => new SetSemantics.deserialize(params), + 'tap': (Map params) => new Tap.deserialize(params), 'waitFor': (Map params) => new WaitFor.deserialize(params), 'waitUntilNoTransientCallbacks': (Map params) => new WaitUntilNoTransientCallbacks.deserialize(params), }); @@ -98,6 +110,10 @@ class FlutterDriverExtension { }); } + final DataHandler _requestDataHandler; + + static final Logger _log = new Logger('FlutterDriverExtension'); + final WidgetController _prober = new WidgetController(WidgetsBinding.instance); final Map _commandHandlers = {}; final Map _commandDeserializers = {}; @@ -117,6 +133,7 @@ class FlutterDriverExtension { /// /// The returned JSON is command specific. Generally the caller deserializes /// the result into a subclass of [Result], but that's not strictly required. + @visibleForTesting Future> call(Map params) async { final String commandKind = params['command']; try { @@ -243,8 +260,8 @@ class FlutterDriverExtension { _prober.binding.hitTest(hitTest, startLocation); _prober.binding.dispatchEvent(pointer.down(startLocation), hitTest); - await new Future.value(); // so that down and move don't happen in the same microtask - for (int moves = 0; moves < totalMoves; moves++) { + await new Future.value(); // so that down and move don't happen in the same microtask + for (int moves = 0; moves < totalMoves; moves += 1) { currentLocation = currentLocation + delta; _prober.binding.dispatchEvent(pointer.move(currentLocation), hitTest); await new Future.delayed(pause); @@ -269,6 +286,11 @@ class FlutterDriverExtension { return new GetTextResult(text.data); } + Future _requestData(Command command) async { + final RequestData requestDataCommand = command; + return new RequestDataResult(_requestDataHandler == null ? '' : await _requestDataHandler(requestDataCommand.message)); + } + Future _setFrameSync(Command command) async { final SetFrameSync setFrameSyncCommand = command; _frameSync = setFrameSyncCommand.enabled; diff --git a/packages/flutter_driver/lib/src/frame_sync.dart b/packages/flutter_driver/lib/src/frame_sync.dart index ad008ef2699c6..dea7bc5549fde 100644 --- a/packages/flutter_driver/lib/src/frame_sync.dart +++ b/packages/flutter_driver/lib/src/frame_sync.dart @@ -9,11 +9,11 @@ class SetFrameSync extends Command { @override final String kind = 'set_frame_sync'; + SetFrameSync(this.enabled, { Duration timeout }) : super(timeout: timeout); + /// Whether frameSync should be enabled or disabled. final bool enabled; - SetFrameSync(this.enabled, { Duration timeout }) : super(timeout: timeout); - /// Deserializes this command from the value generated by [serialize]. SetFrameSync.deserialize(Map params) : this.enabled = params['enabled'].toLowerCase() == 'true', diff --git a/packages/flutter_driver/lib/src/health.dart b/packages/flutter_driver/lib/src/health.dart index da10a27e89363..dc29205930bda 100644 --- a/packages/flutter_driver/lib/src/health.dart +++ b/packages/flutter_driver/lib/src/health.dart @@ -10,12 +10,14 @@ class GetHealth extends Command { @override final String kind = 'get_health'; + /// Create a health check command. GetHealth({Duration timeout}) : super(timeout: timeout); /// Deserializes the command from JSON generated by [serialize]. GetHealth.deserialize(Map json) : super.deserialize(json); } +/// A description of application state. enum HealthStatus { /// Application is known to be in a good shape and should be able to respond. ok, @@ -27,6 +29,8 @@ enum HealthStatus { final EnumIndex _healthStatusIndex = new EnumIndex(HealthStatus.values); +/// A description of the application state, as provided in response to a +/// [FlutterDriver.checkHealth] test. class Health extends Result { /// Creates a [Health] object with the given [status]. Health(this.status) { @@ -38,10 +42,13 @@ class Health extends Result { return new Health(_healthStatusIndex.lookupBySimpleName(json['status'])); } + /// The status represented by this object. + /// + /// If the application responded, this will be [HealthStatus.ok]. final HealthStatus status; @override Map toJson() => { - 'status': _healthStatusIndex.toSimpleName(status) + 'status': _healthStatusIndex.toSimpleName(status), }; } diff --git a/packages/flutter_driver/lib/src/request_data.dart b/packages/flutter_driver/lib/src/request_data.dart new file mode 100644 index 0000000000000..097e06727d1e0 --- /dev/null +++ b/packages/flutter_driver/lib/src/request_data.dart @@ -0,0 +1,46 @@ +// Copyright 2016 The Chromium 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 'message.dart'; + +/// Send a string and get a string response. +class RequestData extends Command { + @override + final String kind = 'request_data'; + + /// Create a command that sends a message. + RequestData(this.message, { Duration timeout }) : super(timeout: timeout); + + /// The message being sent from the test to the application. + final String message; + + /// Deserializes this command from the value generated by [serialize]. + RequestData.deserialize(Map params) + : this.message = params['message'], + super.deserialize(params); + + @override + Map serialize() => super.serialize()..addAll({ + 'message': message, + }); +} + +/// The result of the [RequestData] command. +class RequestDataResult extends Result { + /// Creates a result with the given [message]. + RequestDataResult(this.message); + + /// The text extracted by the [RequestData] command. + final String message; + + /// Deserializes the result from JSON. + static RequestDataResult fromJson(Map json) { + return new RequestDataResult(json['message']); + } + + @override + Map toJson() => { + 'message': message, + }; +} diff --git a/packages/flutter_driver/lib/src/semantics.dart b/packages/flutter_driver/lib/src/semantics.dart index b9af7c15be6d0..83cfe8d2987f0 100644 --- a/packages/flutter_driver/lib/src/semantics.dart +++ b/packages/flutter_driver/lib/src/semantics.dart @@ -9,11 +9,11 @@ class SetSemantics extends Command { @override final String kind = 'set_semantics'; + SetSemantics(this.enabled, { Duration timeout }) : super(timeout: timeout); + /// Whether semantics should be enabled or disabled. final bool enabled; - SetSemantics(this.enabled, { Duration timeout }) : super(timeout: timeout); - /// Deserializes this command from the value generated by [serialize]. SetSemantics.deserialize(Map params) : this.enabled = params['enabled'].toLowerCase() == 'true', diff --git a/packages/flutter_driver/pubspec.yaml b/packages/flutter_driver/pubspec.yaml index 21bd1a4586b39..40926afc2fa9d 100644 --- a/packages/flutter_driver/pubspec.yaml +++ b/packages/flutter_driver/pubspec.yaml @@ -1,5 +1,5 @@ name: flutter_driver -version: 0.0.9-dev +version: 0.0.10-dev description: Integration and performance test API for Flutter applications homepage: http://flutter.io author: Flutter Authors diff --git a/packages/flutter_driver/test/src/extension_test.dart b/packages/flutter_driver/test/src/extension_test.dart index f341033677024..0aec947156ea3 100644 --- a/packages/flutter_driver/test/src/extension_test.dart +++ b/packages/flutter_driver/test/src/extension_test.dart @@ -5,16 +5,19 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter_driver/src/extension.dart'; import 'package:flutter_driver/src/find.dart'; +import 'package:flutter_driver/src/request_data.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('waitUntilNoTransientCallbacks', () { FlutterDriverExtension extension; Map result; + int messageId = 0; + final List log = []; setUp(() { result = null; - extension = new FlutterDriverExtension(); + extension = new FlutterDriverExtension((String message) async { log.add(message); return (messageId += 1).toString(); }); }); testWidgets('returns immediately when transient callback queue is empty', (WidgetTester tester) async { @@ -57,5 +60,12 @@ void main() { }, ); }); + + testWidgets('handler', (WidgetTester tester) async { + expect(log, isEmpty); + final dynamic result = RequestDataResult.fromJson((await extension.call(new RequestData('hello').serialize()))['response']); + expect(log, ['hello']); + expect(result.message, '1'); + }); }); } diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart index ea9f515766031..c7b8e0e027fe5 100644 --- a/packages/flutter_test/lib/src/matchers.dart +++ b/packages/flutter_test/lib/src/matchers.dart @@ -117,6 +117,23 @@ const Matcher isNotInCard = const _IsNotInCard(); /// empty, and does not contain the default `Instance of ...` string. const Matcher hasOneLineDescription = const _HasOneLineDescription(); +/// Asserts that an object's toStringDeep() is a plausible multi-line +/// description. +/// +/// Specifically, this matcher checks that an object's +/// `toStringDeep(prefixLineOne, prefixOtherLines)`: +/// +/// * Does not have leading or trailing whitespace. +/// * Does not contain the default `Instance of ...` string. +/// * The last line has characters other than tree connector characters and +/// whitespace. For example: the line ` │ ║ ╎` has only tree connector +/// characters and whitespace. +/// * Does not contain lines with trailing white space. +/// * Has multiple lines. +/// * The first line starts with `prefixLineOne` +/// * All subsequent lines start with `prefixOtherLines`. +const Matcher hasAGoodToStringDeep = const _HasGoodToStringDeep(); + /// A matcher for functions that throw [FlutterError]. /// /// This is equivalent to `throwsA(const isInstanceOf())`. @@ -179,6 +196,23 @@ Matcher moreOrLessEquals(double value, { double epsilon: 1e-10 }) { return new _MoreOrLessEquals(value, epsilon); } +/// Asserts that two [String]s are equal after normalizing likely hash codes. +/// +/// A `#` followed by 5 hexadecimal digits is assumed to be a short hash code +/// and is normalized to #00000. +/// +/// See Also: +/// +/// * [describeIdentity], a method that generates short descriptions of objects +/// with ids that match the pattern #[0-9a-f]{5}. +/// * [shortHash], a method that generates a 5 character long hexadecimal +/// [String] based on [Object.hashCode]. +/// * [TreeDiagnosticsMixin.toStringDeep], a method that returns a [String] +/// typically containing multiple hash codes. +Matcher equalsIgnoringHashCodes(String value) { + return new _EqualsIgnoringHashCodes(value); +} + class _FindsWidgetMatcher extends Matcher { const _FindsWidgetMatcher(this.min, this.max); @@ -352,6 +386,185 @@ class _HasOneLineDescription extends Matcher { Description describe(Description description) => description.add('one line description'); } +class _EqualsIgnoringHashCodes extends Matcher { + _EqualsIgnoringHashCodes(String v) : _value = _normalize(v); + + final String _value; + + static final Object _mismatchedValueKey = new Object(); + + static String _normalize(String s) { + return s.replaceAll(new RegExp(r'#[0-9a-f]{5}'), '#00000'); + } + + @override + bool matches(dynamic object, Map matchState) { + final String description = _normalize(object); + if (_value != description) { + matchState[_mismatchedValueKey] = description; + return false; + } + return true; + } + + @override + Description describe(Description description) { + return description.add('multi line description equals $_value'); + } + + @override + Description describeMismatch( + dynamic item, + Description mismatchDescription, + Map matchState, + bool verbose + ) { + if (matchState.containsKey(_mismatchedValueKey)) { + final String actualValue = matchState[_mismatchedValueKey]; + // Leading whitespace is added so that lines in the multi-line + // description returned by addDescriptionOf are all indented equally + // which makes the output easier to read for this case. + return mismatchDescription + .add('expected normalized value\n ') + .addDescriptionOf(_value) + .add('\nbut got\n ') + .addDescriptionOf(actualValue); + } + return mismatchDescription; + } +} + +/// Returns `true` if [c] represents a whitespace code unit. +bool _isWhitespace(int c) => (c <= 0x000D && c >= 0x0009) || c == 0x0020; + +/// Returns `true` if [c] represents a vertical line unicode line art code unit. +/// +/// See [https://en.wikipedia.org/wiki/Box-drawing_character]. This method only +/// specifies vertical line art code units currently used by Flutter line art. +/// There are other line art characters that technically also represent vertical +/// lines. +bool _isVerticalLine(int c) { + return c == 0x2502 || c == 0x2503 || c == 0x2551 || c == 0x254e; +} + +/// Returns whether a [line] is all vertical tree connector characters. +/// +/// Example vertical tree connector characters: `│ ║ ╎`. +/// The last line of a text tree contains only vertical tree connector +/// characters indicates a poorly formatted tree. +bool _isAllTreeConnectorCharacters(String line) { + for (int i = 0; i < line.length; ++i) { + final int c = line.codeUnitAt(i); + if (!_isWhitespace(c) && !_isVerticalLine(c)) + return false; + } + return true; +} + +class _HasGoodToStringDeep extends Matcher { + const _HasGoodToStringDeep(); + + static final Object _toStringDeepErrorDescriptionKey = new Object(); + + @override + bool matches(dynamic object, Map matchState) { + final List issues = []; + String description = object.toStringDeep(); + if (description.endsWith('\n')) { + // Trim off trailing \n as the remaining calculations assume + // the description does not end with a trailing \n. + description = description.substring(0, description.length - 1); + } else { + issues.add('Not terminated with a line break.'); + } + + if (description.trim() != description) + issues.add('Has trailing whitespace.'); + + final List lines = description.split('\n'); + if (lines.length < 2) + issues.add('Does not have multiple lines.'); + + if (description.contains('Instance of ')) + issues.add('Contains text "Instance of ".'); + + for (int i = 0; i < lines.length; ++i) { + final String line = lines[i]; + if (line.isEmpty) + issues.add('Line ${i+1} is empty.'); + + if (line.trimRight() != line) + issues.add('Line ${i+1} has trailing whitespace.'); + } + + if (_isAllTreeConnectorCharacters(lines.last)) + issues.add('Last line is all tree connector characters.'); + + // If a toStringDeep method doesn't properly handle nested values that + // contain line breaks it can fail to add the required prefixes to all + // lined when toStringDeep is called specifying prefixes. + final String prefixLineOne = 'PREFIX_LINE_ONE____'; + final String prefixOtherLines = 'PREFIX_OTHER_LINES_'; + final List prefixIssues = []; + String descriptionWithPrefixes = + object.toStringDeep(prefixLineOne, prefixOtherLines); + if (descriptionWithPrefixes.endsWith('\n')) { + // Trim off trailing \n as the remaining calculations assume + // the description does not end with a trailing \n. + descriptionWithPrefixes = descriptionWithPrefixes.substring( + 0, descriptionWithPrefixes.length - 1); + } + final List linesWithPrefixes = descriptionWithPrefixes.split('\n'); + if (!linesWithPrefixes.first.startsWith(prefixLineOne)) + prefixIssues.add('First line does not contain expected prefix.'); + + for (int i = 1; i < linesWithPrefixes.length; ++i) { + if (!linesWithPrefixes[i].startsWith(prefixOtherLines)) + prefixIssues.add('Line ${i+1} does not contain the expected prefix.'); + } + + final StringBuffer errorDescription = new StringBuffer(); + if (issues.isNotEmpty) { + errorDescription.writeln('Bad toStringDeep():'); + errorDescription.writeln(description); + errorDescription.writeAll(issues, '\n'); + } + + if (prefixIssues.isNotEmpty) { + errorDescription.writeln( + 'Bad toStringDeep("$prefixLineOne", "$prefixOtherLines"):'); + errorDescription.writeln(descriptionWithPrefixes); + errorDescription.writeAll(prefixIssues, '\n'); + } + + if (errorDescription.isNotEmpty) { + matchState[_toStringDeepErrorDescriptionKey] = + errorDescription.toString(); + return false; + } + return true; + } + + @override + Description describeMismatch( + dynamic item, + Description mismatchDescription, + Map matchState, + bool verbose + ) { + if (matchState.containsKey(_toStringDeepErrorDescriptionKey)) { + return mismatchDescription.add( + matchState[_toStringDeepErrorDescriptionKey]); + } + return mismatchDescription; + } + + @override + Description describe(Description description) { + return description.add('multi line description'); + } +} + class _MoreOrLessEquals extends Matcher { const _MoreOrLessEquals(this.value, this.epsilon); diff --git a/packages/flutter_test/pubspec.yaml b/packages/flutter_test/pubspec.yaml index d164f2a4ef4dc..a2b6e2342b2b0 100644 --- a/packages/flutter_test/pubspec.yaml +++ b/packages/flutter_test/pubspec.yaml @@ -1,5 +1,5 @@ name: flutter_test -version: 0.0.9-dev +version: 0.0.10-dev dependencies: # The flutter tools depend on very specific internal implementation # details of the 'test' package, which change between versions, so diff --git a/packages/flutter_test/test/matchers_test.dart b/packages/flutter_test/test/matchers_test.dart index 64ed12747cb04..b382463ea6b8c 100644 --- a/packages/flutter_test/test/matchers_test.dart +++ b/packages/flutter_test/test/matchers_test.dart @@ -4,6 +4,43 @@ import 'package:flutter_test/flutter_test.dart'; +/// Class that makes it easy to mock common toStringDeep behavior. +class _MockToStringDeep { + _MockToStringDeep(String str) { + final List lines = str.split('\n'); + _lines = []; + for (int i = 0; i < lines.length - 1; ++i) + _lines.add('${lines[i]}\n'); + + // If the last line is empty, that really just means that the previous + // line was terminated with a line break. + if (lines.isNotEmpty && lines.last.isNotEmpty) { + _lines.add(lines.last); + } + } + + _MockToStringDeep.fromLines(this._lines); + + /// Lines in the message to display when [toStringDeep] is called. + /// For correct toStringDeep behavior, each line should be terminated with a + /// line break. + List _lines; + + String toStringDeep([String prefixLineOne="", String prefixOtherLines=""]) { + final StringBuffer sb = new StringBuffer(); + if (_lines.isNotEmpty) + sb.write('$prefixLineOne${_lines.first}'); + + for (int i = 1; i < _lines.length; ++i) + sb.write('$prefixOtherLines${_lines[i]}'); + + return sb.toString(); + } + + @override + String toString() => toStringDeep(); +} + void main() { test('hasOneLineDescription', () { expect('Hello', hasOneLineDescription); @@ -13,6 +50,113 @@ void main() { expect(new Object(), isNot(hasOneLineDescription)); }); + test('hasAGoodToStringDeep', () { + expect(new _MockToStringDeep('Hello\n World\n'), hasAGoodToStringDeep); + // Not terminated with a line break. + expect(new _MockToStringDeep('Hello\n World'), isNot(hasAGoodToStringDeep)); + // Trailing whitespace on last line. + expect(new _MockToStringDeep('Hello\n World \n'), + isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('Hello\n World\t\n'), + isNot(hasAGoodToStringDeep)); + // Leading whitespace on line 1. + expect(new _MockToStringDeep(' Hello\n World \n'), + isNot(hasAGoodToStringDeep)); + + // Single line. + expect(new _MockToStringDeep('Hello World'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('Hello World\n'), isNot(hasAGoodToStringDeep)); + + expect(new _MockToStringDeep('Hello: World\nFoo: bar\n'), + hasAGoodToStringDeep); + expect(new _MockToStringDeep('Hello: World\nFoo: 42\n'), + hasAGoodToStringDeep); + // Contains default Object.toString(). + expect(new _MockToStringDeep('Hello: World\nFoo: ${new Object()}\n'), + isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n'), hasAGoodToStringDeep); + expect(new _MockToStringDeep('A\n├─B\n╘══════\n'), hasAGoodToStringDeep); + // Last line is all whitespace or vertical line art. + expect(new _MockToStringDeep('A\n├─B\n\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n│\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n│\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n│\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n╎\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n║\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n │\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n ╎\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n ║\n'), isNot(hasAGoodToStringDeep)); + expect(new _MockToStringDeep('A\n├─B\n ││\n'), isNot(hasAGoodToStringDeep)); + + expect(new _MockToStringDeep( + 'A\n' + '├─B\n' + '│\n' + '└─C\n'), hasAGoodToStringDeep); + // Last line is all whitespace or vertical line art. + expect(new _MockToStringDeep( + 'A\n' + '├─B\n' + '│\n'), isNot(hasAGoodToStringDeep)); + + expect(new _MockToStringDeep.fromLines( + ['Paragraph#00000\n', + ' │ size: (400x200)\n', + ' ╘═╦══ text ═══\n', + ' ║ TextSpan:\n', + ' ║ "I polished up that handle so carefullee\n', + ' ║ That now I am the Ruler of the Queen\'s Navee!"\n', + ' ╚═══════════\n']), hasAGoodToStringDeep); + + // Text span + expect(new _MockToStringDeep.fromLines( + ['Paragraph#00000\n', + ' │ size: (400x200)\n', + ' ╘═╦══ text ═══\n', + ' ║ TextSpan:\n', + ' ║ "I polished up that handle so carefullee\nThat now I am the Ruler of the Queen\'s Navee!"\n', + ' ╚═══════════\n']), isNot(hasAGoodToStringDeep)); + }); + + test('normalizeHashCodesEquals', () { + expect('Foo#34219', equalsIgnoringHashCodes('Foo#00000')); + expect('Foo#34219', equalsIgnoringHashCodes('Foo#12345')); + expect('Foo#34219', equalsIgnoringHashCodes('Foo#abcdf')); + expect('Foo#34219', isNot(equalsIgnoringHashCodes('Foo'))); + expect('Foo#34219', isNot(equalsIgnoringHashCodes('Foo#'))); + expect('Foo#34219', isNot(equalsIgnoringHashCodes('Foo#0'))); + expect('Foo#34219', isNot(equalsIgnoringHashCodes('Foo#00'))); + expect('Foo#34219', isNot(equalsIgnoringHashCodes('Foo#00000 '))); + expect('Foo#34219', isNot(equalsIgnoringHashCodes('Foo#000000'))); + expect('Foo#34219', isNot(equalsIgnoringHashCodes('Foo#123456'))); + + expect('Foo#34219:', equalsIgnoringHashCodes('Foo#00000:')); + expect('Foo#34219:', isNot(equalsIgnoringHashCodes('Foo#00000'))); + + expect('Foo#a3b4d', equalsIgnoringHashCodes('Foo#00000')); + expect('Foo#a3b4d', equalsIgnoringHashCodes('Foo#12345')); + expect('Foo#a3b4d', equalsIgnoringHashCodes('Foo#abcdf')); + expect('Foo#a3b4d', isNot(equalsIgnoringHashCodes('Foo'))); + expect('Foo#a3b4d', isNot(equalsIgnoringHashCodes('Foo#'))); + expect('Foo#a3b4d', isNot(equalsIgnoringHashCodes('Foo#0'))); + expect('Foo#a3b4d', isNot(equalsIgnoringHashCodes('Foo#00'))); + expect('Foo#a3b4d', isNot(equalsIgnoringHashCodes('Foo#00000 '))); + expect('Foo#a3b4d', isNot(equalsIgnoringHashCodes('Foo#000000'))); + expect('Foo#a3b4d', isNot(equalsIgnoringHashCodes('Foo#123456'))); + + expect('Foo#A3b4D', isNot(equalsIgnoringHashCodes('Foo#00000'))); + + expect('Foo#12345(Bar#9110f)', + equalsIgnoringHashCodes('Foo#00000(Bar#00000)')); + expect('Foo#12345(Bar#9110f)', + isNot(equalsIgnoringHashCodes('Foo#00000(Bar#)'))); + + expect('Foo', isNot(equalsIgnoringHashCodes('Foo#00000'))); + expect('Foo#', isNot(equalsIgnoringHashCodes('Foo#00000'))); + expect('Foo#3421', isNot(equalsIgnoringHashCodes('Foo#00000'))); + expect('Foo#342193', isNot(equalsIgnoringHashCodes('Foo#00000'))); + }); + test('moreOrLessEquals', () { expect(0.0, moreOrLessEquals(1e-11)); expect(1e-11, moreOrLessEquals(0.0)); diff --git a/packages/flutter_tools/bin/fuchsia_builder.dart b/packages/flutter_tools/bin/fuchsia_builder.dart index 99f08ccb07d95..e0d1ff8b6f7de 100644 --- a/packages/flutter_tools/bin/fuchsia_builder.dart +++ b/packages/flutter_tools/bin/fuchsia_builder.dart @@ -15,6 +15,7 @@ import '../lib/src/base/io.dart'; import '../lib/src/base/logger.dart'; import '../lib/src/base/os.dart'; import '../lib/src/base/platform.dart'; +import '../lib/src/base/terminal.dart'; import '../lib/src/cache.dart'; import '../lib/src/flx.dart'; import '../lib/src/globals.dart'; @@ -51,6 +52,7 @@ Future main(List args) async { context.putIfAbsent(Config, () => new Config()); context.putIfAbsent(OperatingSystemUtils, () => new OperatingSystemUtils()); context.putIfAbsent(Usage, () => new Usage()); + context.putIfAbsent(AnsiTerminal, () => new AnsiTerminal()); return run(args); }); } diff --git a/packages/flutter_tools/bin/fuchsia_tester.dart b/packages/flutter_tools/bin/fuchsia_tester.dart index d4bd938eece8d..8abd2c2652cc2 100644 --- a/packages/flutter_tools/bin/fuchsia_tester.dart +++ b/packages/flutter_tools/bin/fuchsia_tester.dart @@ -17,6 +17,7 @@ import '../lib/src/base/io.dart'; import '../lib/src/base/logger.dart'; import '../lib/src/base/os.dart'; import '../lib/src/base/platform.dart'; +import '../lib/src/base/terminal.dart'; import '../lib/src/cache.dart'; import '../lib/src/dart/package_map.dart'; import '../lib/src/globals.dart'; @@ -47,6 +48,7 @@ Future main(List args) async { context.putIfAbsent(Config, () => new Config()); context.putIfAbsent(OperatingSystemUtils, () => new OperatingSystemUtils()); context.putIfAbsent(Usage, () => new Usage()); + context.putIfAbsent(AnsiTerminal, () => new AnsiTerminal()); return run(args); }); } diff --git a/packages/flutter_tools/lib/src/android/android_device.dart b/packages/flutter_tools/lib/src/android/android_device.dart index 8584455ec0c40..0cad7ffdae196 100644 --- a/packages/flutter_tools/lib/src/android/android_device.dart +++ b/packages/flutter_tools/lib/src/android/android_device.dart @@ -276,6 +276,7 @@ class AndroidDevice extends Device { } if (installResult.exitCode != 0) { printError('Error: ADB exited with exit code ${installResult.exitCode}'); + printError('$installResult'); return false; } diff --git a/packages/flutter_tools/lib/src/asset.dart b/packages/flutter_tools/lib/src/asset.dart index 6f1e39ae87154..7e4c04f039050 100644 --- a/packages/flutter_tools/lib/src/asset.dart +++ b/packages/flutter_tools/lib/src/asset.dart @@ -77,7 +77,7 @@ class AssetBundle { manifest = _loadFlutterManifest(manifestPath); } catch (e) { printStatus('Error detected in pubspec.yaml:', emphasis: true); - printError(e); + printError('$e'); return 1; } if (manifest == null) { @@ -113,8 +113,13 @@ class AssetBundle { manifestDescriptor['uses-material-design']; for (_Asset asset in assetVariants.keys) { - assert(asset.assetFileExists); - entries[asset.assetEntry] = new DevFSFileContent(asset.assetFile); + if (!asset.assetFileExists && assetVariants[asset].isEmpty) { + printStatus('Error detected in pubspec.yaml:', emphasis: true); + printError('No file or variants found for $asset.\n'); + return 1; + } + if (asset.assetFileExists) + entries[asset.assetEntry] = new DevFSFileContent(asset.assetFile); for (_Asset variant in assetVariants[asset]) { assert(variant.assetFileExists); entries[variant.assetEntry] = new DevFSFileContent(variant.assetFile); @@ -313,6 +318,50 @@ DevFSContent _createFontManifest(Map manifestDescriptor, return new DevFSStringContent(JSON.encode(fonts)); } +// Given an assets directory like this: +// +// assets/foo +// assets/var1/foo +// assets/var2/foo +// assets/bar +// +// variantsFor('assets/foo') => ['/assets/var1/foo', '/assets/var2/foo'] +// variantsFor('assets/bar') => [] +class _AssetDirectoryCache { + _AssetDirectoryCache(Iterable excluded) { + _excluded = excluded.map((String path) => fs.path.absolute(path) + fs.path.separator); + } + + Iterable _excluded; + final Map>> _cache = >>{}; + + List variantsFor(String assetPath) { + final String assetName = fs.path.basename(assetPath); + final String directory = fs.path.dirname(assetPath); + + if (_cache[directory] == null) { + final List paths = []; + for (FileSystemEntity entity in fs.directory(directory).listSync(recursive: true)) { + final String path = entity.path; + if (fs.isFileSync(path) && !_excluded.any((String exclude) => path.startsWith(exclude))) + paths.add(path); + } + + final Map> variants = >{}; + for (String path in paths) { + final String variantName = fs.path.basename(path); + if (directory == fs.path.dirname(path)) + continue; + variants[variantName] ??= []; + variants[variantName].add(path); + } + _cache[directory] = variants; + } + + return _cache[directory][assetName] ?? const []; + } +} + /// Given an assetBase location and a pubspec.yaml Flutter manifest, return a /// map of assets to asset variants. /// @@ -328,45 +377,21 @@ Map<_Asset, List<_Asset>> _parseAssets( if (manifestDescriptor == null) return result; - excludeDirs = excludeDirs.map( - (String exclude) => fs.path.absolute(exclude) + fs.path.separator - ).toList(); - if (manifestDescriptor.containsKey('assets')) { - for (String asset in manifestDescriptor['assets']) { - final _Asset baseAsset = _resolveAsset(packageMap, assetBase, asset); - - if (!baseAsset.assetFileExists) { - printError('Error: unable to locate asset entry in pubspec.yaml: "$asset".'); - return null; - } - + final _AssetDirectoryCache cache = new _AssetDirectoryCache(excludeDirs); + for (String assetName in manifestDescriptor['assets']) { + final _Asset asset = _resolveAsset(packageMap, assetBase, assetName); final List<_Asset> variants = <_Asset>[]; - result[baseAsset] = variants; - - // Find asset variants - final String assetPath = baseAsset.assetFile.path; - final String assetFilename = fs.path.basename(assetPath); - final Directory assetDir = fs.directory(fs.path.dirname(assetPath)); - final List files = assetDir.listSync(recursive: true); - - for (FileSystemEntity entity in files) { - if (!fs.isFileSync(entity.path)) - continue; - - // Exclude any files in the given directories. - if (excludeDirs.any((String exclude) => entity.path.startsWith(exclude))) - continue; - - if (fs.path.basename(entity.path) == assetFilename && entity.path != assetPath) { - final String key = fs.path.relative(entity.path, from: baseAsset.base); - String assetEntry; - if (baseAsset.symbolicPrefix != null) - assetEntry = fs.path.join(baseAsset.symbolicPrefix, key); - variants.add(new _Asset(base: baseAsset.base, assetEntry: assetEntry, relativePath: key)); - } + for (String path in cache.variantsFor(asset.assetFile.path)) { + final String key = fs.path.relative(path, from: asset.base); + String assetEntry; + if (asset.symbolicPrefix != null) + assetEntry = fs.path.join(asset.symbolicPrefix, key); + variants.add(new _Asset(base: asset.base, assetEntry: assetEntry, relativePath: key)); } + + result[asset] = variants; } } diff --git a/packages/flutter_tools/lib/src/base/file_system.dart b/packages/flutter_tools/lib/src/base/file_system.dart index 6d5f3bc033a31..6baeecf37f969 100644 --- a/packages/flutter_tools/lib/src/base/file_system.dart +++ b/packages/flutter_tools/lib/src/base/file_system.dart @@ -71,10 +71,11 @@ void ensureDirectoryExists(String filePath) { } } -/// Recursively copies `srcDir` to `destDir`. +/// Recursively copies `srcDir` to `destDir`, invoking [onFileCopied] if +/// specified for each source/destination file pair. /// /// Creates `destDir` if needed. -void copyDirectorySync(Directory srcDir, Directory destDir) { +void copyDirectorySync(Directory srcDir, Directory destDir, [void onFileCopied(File srcFile, File destFile)]) { if (!srcDir.existsSync()) throw new Exception('Source directory "${srcDir.path}" does not exist, nothing to copy'); @@ -86,6 +87,7 @@ void copyDirectorySync(Directory srcDir, Directory destDir) { if (entity is File) { final File newFile = destDir.fileSystem.file(newPath); newFile.writeAsBytesSync(entity.readAsBytesSync()); + onFileCopied?.call(entity, newFile); } else if (entity is Directory) { copyDirectorySync( entity, destDir.fileSystem.directory(newPath)); diff --git a/packages/flutter_tools/lib/src/base/os.dart b/packages/flutter_tools/lib/src/base/os.dart index 1cb56968f03f8..8dfcbb452fa88 100644 --- a/packages/flutter_tools/lib/src/base/os.dart +++ b/packages/flutter_tools/lib/src/base/os.dart @@ -47,6 +47,8 @@ abstract class OperatingSystemUtils { void unzip(File file, Directory targetDirectory); + void unpack(File gzippedTarFile, Directory targetDirectory); + /// Returns a pretty name string for the current operating system. /// /// If available, the detailed version of the OS is included. @@ -97,6 +99,12 @@ class _PosixUtils extends OperatingSystemUtils { runSync(['unzip', '-o', '-q', file.path, '-d', targetDirectory.path]); } + // tar -xzf tarball -C dest + @override + void unpack(File gzippedTarFile, Directory targetDirectory) { + runSync(['tar', '-xzf', gzippedTarFile.path, '-C', targetDirectory.path]); + } + @override File makePipe(String path) { runSync(['mkfifo', path]); @@ -167,7 +175,18 @@ class _WindowsUtils extends OperatingSystemUtils { @override void unzip(File file, Directory targetDirectory) { final Archive archive = new ZipDecoder().decodeBytes(file.readAsBytesSync()); + _unpackArchive(archive, targetDirectory); + } + + @override + void unpack(File gzippedTarFile, Directory targetDirectory) { + final Archive archive = new TarDecoder().decodeBytes( + new GZipDecoder().decodeBytes(gzippedTarFile.readAsBytesSync()), + ); + _unpackArchive(archive, targetDirectory); + } + void _unpackArchive(Archive archive, Directory targetDirectory) { for (ArchiveFile archiveFile in archive.files) { // The archive package doesn't correctly set isFile. if (!archiveFile.isFile || archiveFile.name.endsWith('/')) diff --git a/packages/flutter_tools/lib/src/base/utils.dart b/packages/flutter_tools/lib/src/base/utils.dart index fa17fb336e49b..31ee099195d0b 100644 --- a/packages/flutter_tools/lib/src/base/utils.dart +++ b/packages/flutter_tools/lib/src/base/utils.dart @@ -15,16 +15,20 @@ import 'file_system.dart'; import 'platform.dart'; bool get isRunningOnBot { - // https://docs.travis-ci.com/user/environment-variables/#Default-Environment-Variables - // https://www.appveyor.com/docs/environment-variables/ - // CHROME_HEADLESS is one property set on Flutter's Chrome Infra bots. return - platform.environment['TRAVIS'] == 'true' || platform.environment['BOT'] == 'true' || + + // https://docs.travis-ci.com/user/environment-variables/#Default-Environment-Variables + platform.environment['TRAVIS'] == 'true' || platform.environment['CONTINUOUS_INTEGRATION'] == 'true' || + platform.environment.containsKey('CI') || // Travis and AppVeyor + + // https://www.appveyor.com/docs/environment-variables/ + platform.environment.containsKey('APPVEYOR') || + + // Properties on Flutter's Chrome Infra bots. platform.environment['CHROME_HEADLESS'] == '1' || - platform.environment['APPVEYOR'] == 'true' || - platform.environment['CI'] == 'true'; + platform.environment.containsKey('BUILDBOT_BUILDERNAME'); } String hex(List bytes) { diff --git a/packages/flutter_tools/lib/src/cache.dart b/packages/flutter_tools/lib/src/cache.dart index 5d10d2ab7ad1e..007e7a59ce1b8 100644 --- a/packages/flutter_tools/lib/src/cache.dart +++ b/packages/flutter_tools/lib/src/cache.dart @@ -17,9 +17,19 @@ import 'globals.dart'; /// A wrapper around the `bin/cache/` directory. class Cache { /// [rootOverride] is configurable for testing. - Cache({ Directory rootOverride }) : _rootOverride = rootOverride; + /// [artifacts] is configurable for testing. + Cache({ Directory rootOverride, List artifacts }) : _rootOverride = rootOverride { + if (artifacts == null) { + _artifacts.add(new MaterialFonts(this)); + _artifacts.add(new FlutterEngine(this)); + _artifacts.add(new GradleWrapper(this)); + } else { + _artifacts.addAll(artifacts); + } + } final Directory _rootOverride; + final List _artifacts = []; // Initialized by FlutterCommandRunner on startup. static String flutterRoot; @@ -155,16 +165,9 @@ class Cache { return fs.file(fs.path.join(getRoot().path, '$artifactName.stamp')); } - bool isUpToDate() { - final MaterialFonts materialFonts = new MaterialFonts(cache); - final FlutterEngine engine = new FlutterEngine(cache); - - return materialFonts.isUpToDate() && engine.isUpToDate(); - } + bool isUpToDate() => _artifacts.every((CachedArtifact artifact) => artifact.isUpToDate()); - Future getThirdPartyFile(String urlStr, String serviceName, { - bool unzip: false - }) async { + Future getThirdPartyFile(String urlStr, String serviceName) async { final Uri url = Uri.parse(urlStr); final Directory thirdPartyDir = getArtifactDirectory('third_party'); @@ -175,7 +178,7 @@ class Cache { final File cachedFile = fs.file(fs.path.join(serviceDir.path, url.pathSegments.last)); if (!cachedFile.existsSync()) { try { - await _downloadFileToCache(url, cachedFile, unzip); + await _downloadFile(url, cachedFile); } catch (e) { printError('Failed to fetch third-party artifact $url: $e'); rethrow; @@ -188,77 +191,65 @@ class Cache { Future updateAll() async { if (!_lockEnabled) return null; - final MaterialFonts materialFonts = new MaterialFonts(cache); - if (!materialFonts.isUpToDate()) - await materialFonts.download(); - - final FlutterEngine engine = new FlutterEngine(cache); - if (!engine.isUpToDate()) - await engine.download(); - } - - /// Download a file from the given url and write it to the cache. - /// If [unzip] is true, treat the url as a zip file, and unzip it to the - /// directory given. - static Future _downloadFileToCache(Uri url, FileSystemEntity location, bool unzip) async { - if (!location.parent.existsSync()) - location.parent.createSync(recursive: true); - - final List fileBytes = await fetchUrl(url); - if (unzip) { - if (location is Directory && !location.existsSync()) - location.createSync(recursive: true); - - final File tempFile = fs.file(fs.path.join(fs.systemTempDirectory.path, '${url.toString().hashCode}.zip')); - tempFile.writeAsBytesSync(fileBytes, flush: true); - os.unzip(tempFile, location); - tempFile.deleteSync(); - } else { - final File file = location; - file.writeAsBytesSync(fileBytes, flush: true); + for (CachedArtifact artifact in _artifacts) { + if (!artifact.isUpToDate()) + await artifact.update(); } } } -class MaterialFonts { - MaterialFonts(this.cache); - - static const String kName = 'material_fonts'; +/// An artifact managed by the cache. +abstract class CachedArtifact { + CachedArtifact(this.name, this.cache); + final String name; final Cache cache; + Directory get location => cache.getArtifactDirectory(name); + String get version => cache.getVersionFor(name); + bool isUpToDate() { - if (!cache.getArtifactDirectory(kName).existsSync()) + if (!location.existsSync()) + return false; + if (version != cache.getStampFor(name)) return false; - return cache.getVersionFor(kName) == cache.getStampFor(kName); + return isUpToDateInner(); } - Future download() { - final Status status = logger.startProgress('Downloading Material fonts...', expectSlowOperation: true); + Future update() async { + if (location.existsSync()) + location.deleteSync(recursive: true); + location.createSync(recursive: true); + return updateInner().then((_) { + cache.setStampFor(name, version); + }); + } + + /// Hook method for extra checks for being up-to-date. + bool isUpToDateInner() => true; - final Directory fontsDir = cache.getArtifactDirectory(kName); - if (fontsDir.existsSync()) - fontsDir.deleteSync(recursive: true); + /// Template method to perform artifact update. + Future updateInner(); +} - return Cache._downloadFileToCache( - Uri.parse(cache.getVersionFor(kName)), fontsDir, true - ).then((Null value) { - cache.setStampFor(kName, cache.getVersionFor(kName)); +/// A cached artifact containing fonts used for Material Design. +class MaterialFonts extends CachedArtifact { + MaterialFonts(Cache cache): super('material_fonts', cache); + + @override + Future updateInner() { + final Status status = logger.startProgress('Downloading Material fonts...', expectSlowOperation: true); + return _downloadZipArchive(Uri.parse(version), location).then((_) { status.stop(); }).whenComplete(status.cancel); } } -class FlutterEngine { - - FlutterEngine(this.cache); - - static const String kName = 'engine'; - static const String kSkyEngine = 'sky_engine'; - - final Cache cache; +/// A cached artifact containing the Flutter engine binaries. +class FlutterEngine extends CachedArtifact { + FlutterEngine(Cache cache): super('engine', cache); - List _getPackageDirs() => const [kSkyEngine]; + List _getPackageDirs() => const ['sky_engine']; // Return a list of (cache directory path, download URL path) tuples. List> _getBinaryDirs() { @@ -320,7 +311,8 @@ class FlutterEngine { ['ios-release', 'ios-release/artifacts.zip'], ]; - bool isUpToDate() { + @override + bool isUpToDateInner() { final Directory pkgDir = cache.getCacheDir('pkg'); for (String pkgName in _getPackageDirs()) { final String pkgPath = fs.path.join(pkgDir.path, pkgName); @@ -328,19 +320,17 @@ class FlutterEngine { return false; } - final Directory engineDir = cache.getArtifactDirectory(kName); for (List toolsDir in _getBinaryDirs()) { - final Directory dir = fs.directory(fs.path.join(engineDir.path, toolsDir[0])); + final Directory dir = fs.directory(fs.path.join(location.path, toolsDir[0])); if (!dir.existsSync()) return false; } - - return cache.getVersionFor(kName) == cache.getStampFor(kName); + return true; } - Future download() async { - final String engineVersion = cache.getVersionFor(kName); - final String url = 'https://storage.googleapis.com/flutter_infra/flutter/$engineVersion/'; + @override + Future updateInner() async { + final String url = 'https://storage.googleapis.com/flutter_infra/flutter/$version/'; final Directory pkgDir = cache.getCacheDir('pkg'); for (String pkgName in _getPackageDirs()) { @@ -351,14 +341,10 @@ class FlutterEngine { await _downloadItem('Downloading package $pkgName...', url + pkgName + '.zip', pkgDir); } - final Directory engineDir = cache.getArtifactDirectory(kName); - if (engineDir.existsSync()) - engineDir.deleteSync(recursive: true); - for (List toolsDir in _getBinaryDirs()) { final String cacheDir = toolsDir[0]; final String urlPath = toolsDir[1]; - final Directory dir = fs.directory(fs.path.join(engineDir.path, cacheDir)); + final Directory dir = fs.directory(fs.path.join(location.path, cacheDir)); await _downloadItem('Downloading $cacheDir tools...', url + urlPath, dir); _makeFilesExecutable(dir); @@ -370,8 +356,6 @@ class FlutterEngine { os.unzip(frameworkZip, framework); } } - - cache.setStampFor(kName, cache.getVersionFor(kName)); } void _makeFilesExecutable(Directory dir) { @@ -386,8 +370,68 @@ class FlutterEngine { Future _downloadItem(String message, String url, Directory dest) { final Status status = logger.startProgress(message, expectSlowOperation: true); - return Cache._downloadFileToCache(Uri.parse(url), dest, true).then((Null value) { + return _downloadZipArchive(Uri.parse(url), dest).then((_) { + status.stop(); + }).whenComplete(status.cancel); + } +} + +/// A cached artifact containing Gradle Wrapper scripts and binaries. +class GradleWrapper extends CachedArtifact { + GradleWrapper(Cache cache): super('gradle_wrapper', cache); + + @override + Future updateInner() async { + final Status status = logger.startProgress('Downloading Gradle Wrapper...', expectSlowOperation: true); + + final String url = 'https://android.googlesource.com' + '/platform/tools/base/+archive/$version/templates/gradle/wrapper.tgz'; + await _downloadZippedTarball(Uri.parse(url), location).then((_) { + // Delete property file, allowing templates to provide it. + fs.file(fs.path.join(location.path, 'gradle', 'wrapper', 'gradle-wrapper.properties')).deleteSync(); status.stop(); }).whenComplete(status.cancel); } } + +/// Download a file from the given [url] and write it to [location]. +Future _downloadFile(Uri url, File location) async { + _ensureExists(location.parent); + final List fileBytes = await fetchUrl(url); + location.writeAsBytesSync(fileBytes, flush: true); +} + +/// Download a zip archive from the given [url] and unzip it to [location]. +Future _downloadZipArchive(Uri url, Directory location) { + return _withTemporaryFile('download.zip', (File tempFile) async { + await _downloadFile(url, tempFile); + _ensureExists(location); + os.unzip(tempFile, location); + }); +} + +/// Download a gzipped tarball from the given [url] and unpack it to [location]. +Future _downloadZippedTarball(Uri url, Directory location) { + return _withTemporaryFile('download.tgz', (File tempFile) async { + await _downloadFile(url, tempFile); + _ensureExists(location); + os.unpack(tempFile, location); + }); +} + +/// Create a file with the given name in a new temporary directory, invoke +/// [onTemporaryFile] with the file as argument, then delete the temporary +/// directory. +Future _withTemporaryFile(String name, Future onTemporaryFile(File file)) async { + final Directory tempDir = fs.systemTempDirectory.createTempSync(); + final File tempFile = fs.file(fs.path.join(tempDir.path, name)); + await onTemporaryFile(tempFile).whenComplete(() { + tempDir.delete(recursive: true); + }); +} + +/// Create the given [directory] and parents, as necessary. +void _ensureExists(Directory directory) { + if (!directory.existsSync()) + directory.createSync(recursive: true); +} diff --git a/packages/flutter_tools/lib/src/commands/config.dart b/packages/flutter_tools/lib/src/commands/config.dart index 16c06291930df..6a69305c65dec 100644 --- a/packages/flutter_tools/lib/src/commands/config.dart +++ b/packages/flutter_tools/lib/src/commands/config.dart @@ -13,6 +13,11 @@ class ConfigCommand extends FlutterCommand { argParser.addFlag('analytics', negatable: true, help: 'Enable or disable reporting anonymously tool usage statistics and crash reports.'); + argParser.addFlag( + 'clear-ios-signing-cert', + negatable: false, + help: 'Clear the saved development certificate choice used to sign apps for iOS device deployment' + ); argParser.addOption('gradle-dir', help: 'The gradle install directory.'); argParser.addOption('android-studio-dir', help: 'The Android Studio install directory.'); } @@ -60,6 +65,9 @@ class ConfigCommand extends FlutterCommand { if (argResults.wasParsed('android-studio-dir')) _updateConfig('android-studio-dir', argResults['android-studio-dir']); + if (argResults.wasParsed('clear-ios-signing-cert')) + _updateConfig('ios-signing-cert', ''); + if (argResults.arguments.isEmpty) printStatus(usage); } diff --git a/packages/flutter_tools/lib/src/commands/create.dart b/packages/flutter_tools/lib/src/commands/create.dart index cb60d498f3cf9..0b6f67c73dba0 100644 --- a/packages/flutter_tools/lib/src/commands/create.dart +++ b/packages/flutter_tools/lib/src/commands/create.dart @@ -11,6 +11,7 @@ import '../android/android_sdk.dart' as android_sdk; import '../android/gradle.dart' as gradle; import '../base/common.dart'; import '../base/file_system.dart'; +import '../base/os.dart'; import '../base/utils.dart'; import '../build_info.dart'; import '../cache.dart'; @@ -167,6 +168,10 @@ class CreateCommand extends FlutterCommand { } generatedCount += _renderTemplate('create', appPath, templateContext); + generatedCount += _injectGradleWrapper(appPath); + if (appPath != dirPath) { + generatedCount += _injectGradleWrapper(dirPath); + } if (argResults['with-driver-test']) { final String testPath = fs.path.join(appPath, 'test_driver'); generatedCount += _renderTemplate('driver', testPath, templateContext); @@ -272,6 +277,22 @@ To edit platform code in an IDE see https://flutter.io/platform-plugins/#edit-co final Template template = new Template.fromName(templateName); return template.render(fs.directory(dirPath), context, overwriteExisting: false); } + + int _injectGradleWrapper(String projectDir) { + int filesCreated = 0; + copyDirectorySync( + cache.getArtifactDirectory('gradle_wrapper'), + fs.directory(fs.path.join(projectDir, 'android')), + (File sourceFile, File destinationFile) { + filesCreated++; + final String modes = sourceFile.statSync().modeString(); + if (modes != null && modes.contains('x')) { + os.makeExecutable(destinationFile); + } + }, + ); + return filesCreated; + } } String _createAndroidIdentifier(String organization, String name) { diff --git a/packages/flutter_tools/lib/src/commands/drive.dart b/packages/flutter_tools/lib/src/commands/drive.dart index 0bb14a0e74d75..38aa92050a833 100644 --- a/packages/flutter_tools/lib/src/commands/drive.dart +++ b/packages/flutter_tools/lib/src/commands/drive.dart @@ -41,20 +41,24 @@ class DriveCommand extends RunCommandBase { DriveCommand() { argParser.addFlag( 'keep-app-running', + defaultsTo: null, negatable: true, - defaultsTo: false, help: 'Will keep the Flutter application running when done testing.\n' - 'By default, Flutter drive stops the application after tests are finished.\n' - 'Ignored if --use-existing-app is specified.' + 'By default, "flutter drive" stops the application after tests are finished,\n' + 'and --keep-app-running overrides this. On the other hand, if --use-existing-app\n' + 'is specified, then "flutter drive" instead defaults to leaving the application\n' + 'running, and --no-keep-app-running overrides it.' ); argParser.addOption( 'use-existing-app', help: 'Connect to an already running instance via the given observatory URL.\n' - 'If this option is given, the application will not be automatically started\n' - 'or stopped.' + 'If this option is given, the application will not be automatically started,\n' + 'and it will only be stopped if --no-keep-app-running is explicitly set.', + valueHelp: + 'url' ); } @@ -95,7 +99,7 @@ class DriveCommand extends RunCommandBase { String observatoryUri; if (argResults['use-existing-app'] == null) { - printStatus('Starting application: ${argResults["target"]}'); + printStatus('Starting application: $targetFile'); if (getBuildMode() == BuildMode.release) { // This is because we need VM service to be able to drive the app. @@ -125,11 +129,11 @@ class DriveCommand extends RunCommandBase { rethrow; throwToolExit('CAUGHT EXCEPTION: $error\n$stackTrace'); } finally { - if (!argResults['keep-app-running'] && argResults['use-existing-app'] == null) { + if (argResults['keep-app-running'] ?? (argResults['use-existing-app'] != null)) { + printStatus('Leaving the application running.'); + } else { printStatus('Stopping application instance.'); await appStopper(this); - } else { - printStatus('Leaving the application running.'); } } } @@ -137,7 +141,7 @@ class DriveCommand extends RunCommandBase { String _getTestFile() { String appFile = fs.path.normalize(targetFile); - // This command extends `flutter start` and therefore CWD == package dir + // This command extends `flutter run` and therefore CWD == package dir final String packageDir = fs.currentDirectory.path; // Make appFile path relative to package directory because we are looking @@ -209,7 +213,7 @@ Future findTargetDevice() async { /// Starts the application on the device given command configuration. typedef Future AppStarter(DriveCommand command); -AppStarter appStarter = _startApp; +AppStarter appStarter = _startApp; // (mutable for testing) void restoreAppStarter() { appStarter = _startApp; } @@ -255,7 +259,7 @@ Future _startApp(DriveCommand command) async { observatoryPort: command.observatoryPort, diagnosticPort: command.diagnosticPort, ), - platformArgs: platformArgs + platformArgs: platformArgs, ); if (!result.started) { diff --git a/packages/flutter_tools/lib/src/commands/fuchsia_reload.dart b/packages/flutter_tools/lib/src/commands/fuchsia_reload.dart index 4af62daf71253..118b418b686fb 100644 --- a/packages/flutter_tools/lib/src/commands/fuchsia_reload.dart +++ b/packages/flutter_tools/lib/src/commands/fuchsia_reload.dart @@ -4,12 +4,12 @@ import 'dart:async'; import 'dart:collection'; -import 'dart:math'; import '../base/common.dart'; import '../base/file_system.dart'; import '../base/io.dart'; import '../base/platform.dart'; +import '../base/process_manager.dart'; import '../base/utils.dart'; import '../cache.dart'; import '../device.dart'; @@ -70,6 +70,7 @@ class FuchsiaReloadCommand extends FlutterCommand { final String description = 'Hot reload on Fuchsia.'; String _fuchsiaRoot; + String _buildType; String _projectRoot; String _projectName; String _binaryName; @@ -143,9 +144,24 @@ class FuchsiaReloadCommand extends FlutterCommand { return _vmServiceCache[port]; } + Future _checkPort(int port) async { + bool connected = true; + Socket s; + try { + s = await Socket.connect("$_address", port); + } catch (_) { + connected = false; + } + if (s != null) + await s.close(); + return connected; + } + Future> _getViews(List ports) async { final List views = []; for (int port in ports) { + if (!await _checkPort(port)) + continue; final VMService vmService = _getVMService(port); await vmService.getVM(); await vmService.waitForViews(); @@ -263,6 +279,10 @@ class FuchsiaReloadCommand extends FlutterCommand { if (_address == null) throwToolExit('Give the address of the device running Fuchsia with --address.'); + _buildType = argResults['build-type']; + if (_buildType == null) + throwToolExit('Give the build type with --build-type.'); + _list = argResults['list']; if (_list) { // For --list, we only need the device address and the Fuchsia tree root. @@ -286,11 +306,8 @@ class FuchsiaReloadCommand extends FlutterCommand { if (!_fileExists(_target)) throwToolExit('Couldn\'t find application entry point at $_target.'); - final String buildType = argResults['build-type']; - if (buildType == null) - throwToolExit('Give the build type with --build-type.'); final String packagesFileName = '${_projectName}_dart_package.packages'; - _dotPackagesPath = '$_fuchsiaRoot/out/$buildType/gen/$_projectRoot/$packagesFileName'; + _dotPackagesPath = '$_fuchsiaRoot/out/$_buildType/gen/$_projectRoot/$packagesFileName'; if (!_fileExists(_dotPackagesPath)) throwToolExit('Couldn\'t find .packages file at $_dotPackagesPath.'); @@ -326,7 +343,8 @@ class FuchsiaReloadCommand extends FlutterCommand { } Future> _getServicePorts() async { - final FuchsiaDeviceCommandRunner runner = new FuchsiaDeviceCommandRunner(_fuchsiaRoot); + final FuchsiaDeviceCommandRunner runner = + new FuchsiaDeviceCommandRunner(_address, _fuchsiaRoot, _buildType); final List lsOutput = await runner.run('ls /tmp/dart.services'); final List ports = []; for (String s in lsOutput) { @@ -354,41 +372,26 @@ class FuchsiaReloadCommand extends FlutterCommand { } -// TODO(zra): When Fuchsia has ssh, this should be changed to use that instead. class FuchsiaDeviceCommandRunner { + // TODO(zra): Get rid of _address and instead use + // $_fuchsiaRoot/out/build-magenta/tools/netaddr --fuchsia + final String _address; + final String _buildType; final String _fuchsiaRoot; - final Random _rng = new Random(new DateTime.now().millisecondsSinceEpoch); - FuchsiaDeviceCommandRunner(this._fuchsiaRoot); + FuchsiaDeviceCommandRunner(this._address, this._fuchsiaRoot, this._buildType); Future> run(String command) async { - final int tag = _rng.nextInt(999999); - const String kNetRunCommand = 'out/build-magenta/tools/netruncmd'; - final String netruncmd = fs.path.join(_fuchsiaRoot, kNetRunCommand); - const String kNetCP = 'out/build-magenta/tools/netcp'; - final String netcp = fs.path.join(_fuchsiaRoot, kNetCP); - final String remoteStdout = '/tmp/netruncmd.$tag'; - final String localStdout = '${fs.systemTempDirectory.path}/netruncmd.$tag'; - final String redirectedCommand = '$command > $remoteStdout'; - // Run the command with output directed to a tmp file. - ProcessResult result = - await Process.run(netruncmd, [':', redirectedCommand]); - if (result.exitCode != 0) + final String config = '$_fuchsiaRoot/out/$_buildType/ssh-keys/ssh_config'; + final List args = ['-F', config, _address, command]; + printTrace('ssh ${args.join(' ')}'); + final ProcessResult result = + await processManager.run(['ssh', '-F', config, _address, command]); + if (result.exitCode != 0) { + printStatus("Command failed: $command\nstdout: ${result.stdout}\nstderr: ${result.stderr}"); return null; - // Copy that file to the local filesystem. - result = await Process.run(netcp, [':$remoteStdout', localStdout]); - // Try to delete the remote file. Don't care about the result; - Process.run(netruncmd, [':', 'rm $remoteStdout']); - if (result.exitCode != 0) - return null; - // Read the local file. - final File f = fs.file(localStdout); - List lines; - try { - lines = await f.readAsLines(); - } finally { - f.delete(); } - return lines; + printTrace(result.stdout); + return result.stdout.split('\n'); } } diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart index bc596fb1b05b4..397a93b6d5148 100644 --- a/packages/flutter_tools/lib/src/commands/run.dart +++ b/packages/flutter_tools/lib/src/commands/run.dart @@ -317,8 +317,8 @@ class RunCommand extends RunCommandBase { } DateTime appStartedTime; - // Sync completer so the completing agent attaching to the resident doesn't - // need to know about analytics. + // Sync completer so the completing agent attaching to the resident doesn't + // need to know about analytics. // // Do not add more operations to the future. final Completer appStartedTimeRecorder = new Completer.sync(); @@ -338,7 +338,7 @@ class RunCommand extends RunCommandBase { analyticsParameters: [ hotMode ? 'hot' : 'cold', getModeName(getBuildMode()), - devices.length == 1 + devices.length == 1 ? getNameForTargetPlatform(await devices[0].targetPlatform) : 'multiple', devices.length == 1 && await devices[0].isLocalEmulator ? 'emulator' : null diff --git a/packages/flutter_tools/lib/src/devfs.dart b/packages/flutter_tools/lib/src/devfs.dart index 2657e9254201e..39b9766be1678 100644 --- a/packages/flutter_tools/lib/src/devfs.dart +++ b/packages/flutter_tools/lib/src/devfs.dart @@ -5,6 +5,8 @@ import 'dart:async'; import 'dart:convert' show BASE64, UTF8; +import 'package:json_rpc_2/json_rpc_2.dart' as rpc; + import 'asset.dart'; import 'base/context.dart'; import 'base/file_system.dart'; @@ -185,10 +187,7 @@ class ServiceProtocolDevFSOperations implements DevFSOperations { @override Future destroy(String fsName) async { - await vmService.vm.invokeRpcRaw( - '_deleteDevFS', - params: { 'fsName': fsName }, - ); + await vmService.vm.deleteDevFS(fsName); } @override @@ -352,7 +351,16 @@ class DevFS { Future create() async { printTrace('DevFS: Creating new filesystem on the device ($_baseUri)'); - _baseUri = await _operations.create(fsName); + try { + _baseUri = await _operations.create(fsName); + } on rpc.RpcException catch (rpcException) { + // 1001 is kFileSystemAlreadyExists in //dart/runtime/vm/json_stream.h + if (rpcException.code != 1001) + rethrow; + printTrace('DevFS: Creating failed. Destroying and trying again'); + await destroy(); + _baseUri = await _operations.create(fsName); + } printTrace('DevFS: Created new filesystem on the device ($_baseUri)'); return _baseUri; } diff --git a/packages/flutter_tools/lib/src/ios/code_signing.dart b/packages/flutter_tools/lib/src/ios/code_signing.dart index 169332dc04628..3983fa304c76d 100644 --- a/packages/flutter_tools/lib/src/ios/code_signing.dart +++ b/packages/flutter_tools/lib/src/ios/code_signing.dart @@ -164,9 +164,21 @@ Future _chooseSigningIdentity(List validCodeSigningIdentities) a return validCodeSigningIdentities.first; if (validCodeSigningIdentities.length > 1) { + final String savedCertChoice = config.getValue('ios-signing-cert'); + + if (savedCertChoice != null) { + if (validCodeSigningIdentities.contains(savedCertChoice)) { + printStatus('Found saved certificate choice "$savedCertChoice". To clear, use "flutter config".'); + return savedCertChoice; + } + else { + printError('Saved signing certificate "$savedCertChoice" is not a valid development certificate'); + } + } + final int count = validCodeSigningIdentities.length; printStatus( - 'Multiple valid development certificates available:', + 'Multiple valid development certificates available (your choice will be saved):', emphasis: true, ); for (int i=0; i _chooseSigningIdentity(List validCodeSigningIdentities) a defaultChoiceIndex: 0, // Just pressing enter chooses the first one. ); - if (choice == 'a') + if (choice == 'a') { throwToolExit('Aborted. Code signing is required to build a deployable iOS app.'); - else - return validCodeSigningIdentities[int.parse(choice) - 1]; + } else { + final String selectedCert = validCodeSigningIdentities[int.parse(choice) - 1]; + printStatus('Certificate choice "$savedCertChoice" saved'); + config.setValue('ios-signing-cert', selectedCert); + return selectedCert; + } } return null; diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index c13e5e1acb54a..3c961e7c97171 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -25,7 +25,7 @@ import 'code_signing.dart'; import 'ios_workflow.dart'; import 'xcodeproj.dart'; -const int kXcodeRequiredVersionMajor = 7; +const int kXcodeRequiredVersionMajor = 8; const int kXcodeRequiredVersionMinor = 0; // The Python `six` module is a dependency for Xcode builds, and installed by @@ -80,55 +80,58 @@ class IMobileDevice { } class Xcode { - Xcode() { - _eulaSigned = false; + bool get isInstalledAndMeetsVersionCheck => isInstalled && xcodeVersionSatisfactory; - try { - _xcodeSelectPath = runSync(['xcode-select', '--print-path'])?.trim(); - if (_xcodeSelectPath == null || _xcodeSelectPath.isEmpty) { - _isInstalled = false; - return; - } - _isInstalled = true; - - _xcodeVersionText = runSync(['xcodebuild', '-version']).replaceAll('\n', ', '); - - if (!xcodeVersionRegex.hasMatch(_xcodeVersionText)) { - _isInstalled = false; - } else { - try { - printTrace('xcrun clang'); - final ProcessResult result = processManager.runSync(['/usr/bin/xcrun', 'clang']); - - if (result.stdout != null && result.stdout.contains('license')) - _eulaSigned = false; - else if (result.stderr != null && result.stderr.contains('license')) - _eulaSigned = false; - else - _eulaSigned = true; - } catch (error) { - _eulaSigned = false; - } + String _xcodeSelectPath; + String get xcodeSelectPath { + if (_xcodeSelectPath == null) { + try { + _xcodeSelectPath = processManager.runSync(['/usr/bin/xcode-select', '--print-path']).stdout.trim(); + } on ProcessException { + // Ignore: return null below. } - } catch (error) { - _isInstalled = false; } + return _xcodeSelectPath; } - bool get isInstalledAndMeetsVersionCheck => isInstalled && xcodeVersionSatisfactory; - - String _xcodeSelectPath; - String get xcodeSelectPath => _xcodeSelectPath; - - bool _isInstalled; - bool get isInstalled => _isInstalled; + bool get isInstalled { + if (xcodeSelectPath == null || xcodeSelectPath.isEmpty) + return false; + if (xcodeVersionText == null || !xcodeVersionRegex.hasMatch(xcodeVersionText)) + return false; + return true; + } bool _eulaSigned; /// Has the EULA been signed? - bool get eulaSigned => _eulaSigned; + bool get eulaSigned { + if (_eulaSigned == null) { + try { + final ProcessResult result = processManager.runSync(['/usr/bin/xcrun', 'clang']); + if (result.stdout != null && result.stdout.contains('license')) + _eulaSigned = false; + else if (result.stderr != null && result.stderr.contains('license')) + _eulaSigned = false; + else + _eulaSigned = true; + } on ProcessException { + _eulaSigned = false; + } + } + return _eulaSigned; + } String _xcodeVersionText; - String get xcodeVersionText => _xcodeVersionText; + String get xcodeVersionText { + if (_xcodeVersionText == null) { + try { + _xcodeVersionText = processManager.runSync(['/usr/bin/xcodebuild', '-version']).stdout.replaceAll('\n', ', '); + } on ProcessException { + // Ignore: return null below. + } + } + return _xcodeVersionText; + } int _xcodeMajorVersion; int get xcodeMajorVersion => _xcodeMajorVersion; @@ -139,7 +142,7 @@ class Xcode { final RegExp xcodeVersionRegex = new RegExp(r'Xcode ([0-9.]+)'); bool get xcodeVersionSatisfactory { - if (!xcodeVersionRegex.hasMatch(xcodeVersionText)) + if (xcodeVersionText == null || !xcodeVersionRegex.hasMatch(xcodeVersionText)) return false; final String version = xcodeVersionRegex.firstMatch(xcodeVersionText).group(1); @@ -152,10 +155,15 @@ class Xcode { } Future getAvailableDevices() async { - final RunResult result = await runAsync(['/usr/bin/instruments', '-s', 'devices']); - if (result.exitCode != 0) + try { + final ProcessResult result = await processManager.run( + ['/usr/bin/instruments', '-s', 'devices']); + if (result.exitCode != 0) + throw new ToolExit('/usr/bin/instruments returned an error:\n${result.stderr}'); + return result.stdout; + } on ProcessException { throw new ToolExit('Failed to invoke /usr/bin/instruments. Is Xcode installed?'); - return result.stdout; + } } } @@ -295,8 +303,9 @@ Future diagnoseXcodeBuildFailure(XcodeBuildResult result, BuildableIOSApp } if (result.xcodeBuildExecution != null && result.xcodeBuildExecution.buildForPhysicalDevice && - // Make sure the user has specified at least the DEVELOPMENT_TEAM (for automatic Xcode 8) - // signing or the PROVISIONING_PROFILE (for manual signing or Xcode 7). + // Make sure the user has specified one of: + // DEVELOPMENT_TEAM (automatic signing) + // PROVISIONING_PROFILE (manual signing) !(app.buildSettings?.containsKey('DEVELOPMENT_TEAM')) == true || app.buildSettings?.containsKey('PROVISIONING_PROFILE') == true) { printError(noDevelopmentTeamInstruction, emphasis: true); return; @@ -356,7 +365,7 @@ class XcodeBuildExecution { } final RegExp _xcodeVersionRegExp = new RegExp(r'Xcode (\d+)\..*'); -final String _xcodeRequirement = 'Xcode 7.0 or greater is required to develop for iOS.'; +final String _xcodeRequirement = 'Xcode $kXcodeRequiredVersionMajor.$kXcodeRequiredVersionMinor or greater is required to develop for iOS.'; bool _checkXcodeVersion() { if (!platform.isMacOS) @@ -364,7 +373,7 @@ bool _checkXcodeVersion() { try { final String version = runCheckedSync(['xcodebuild', '-version']); final Match match = _xcodeVersionRegExp.firstMatch(version); - if (int.parse(match[1]) < 7) { + if (int.parse(match[1]) < kXcodeRequiredVersionMajor) { printError('Found "${match[0]}". $_xcodeRequirement'); return false; } diff --git a/packages/flutter_tools/lib/src/services.dart b/packages/flutter_tools/lib/src/services.dart index 00bd1073b9785..1aaf0d28d4fc5 100644 --- a/packages/flutter_tools/lib/src/services.dart +++ b/packages/flutter_tools/lib/src/services.dart @@ -69,20 +69,18 @@ Future parseServiceConfigs( if (jars != null && serviceConfig['jars'] is Iterable) { for (String jar in serviceConfig['jars']) - jars.add(fs.file(await getServiceFromUrl(jar, serviceRoot, service, unzip: false))); + jars.add(fs.file(await getServiceFromUrl(jar, serviceRoot, service))); } } } -Future getServiceFromUrl( - String url, String rootDir, String serviceName, { bool unzip: false } -) async { +Future getServiceFromUrl(String url, String rootDir, String serviceName) async { if (url.startsWith("android-sdk:") && androidSdk != null) { // It's something shipped in the standard android SDK. return url.replaceAll('android-sdk:', '${androidSdk.directory}/'); } else if (url.startsWith("http")) { // It's a regular file to download. - return await cache.getThirdPartyFile(url, serviceName, unzip: unzip); + return await cache.getThirdPartyFile(url, serviceName); } else { // Assume url is a path relative to the service's root dir. return fs.path.join(rootDir, url); diff --git a/packages/flutter_tools/lib/src/test/watcher.dart b/packages/flutter_tools/lib/src/test/watcher.dart index 114f456151d30..497531592a50f 100644 --- a/packages/flutter_tools/lib/src/test/watcher.dart +++ b/packages/flutter_tools/lib/src/test/watcher.dart @@ -7,7 +7,6 @@ import '../base/io.dart' show Process; /// Callbacks for reporting progress while running tests. class TestWatcher { - /// Called after a child process starts. /// /// If startPaused was true, the caller needs to resume in Observatory to diff --git a/packages/flutter_tools/lib/src/usage.dart b/packages/flutter_tools/lib/src/usage.dart index 3f122a0e5b7d2..a5d9683958d71 100644 --- a/packages/flutter_tools/lib/src/usage.dart +++ b/packages/flutter_tools/lib/src/usage.dart @@ -9,12 +9,11 @@ import 'package:usage/usage_io.dart'; import 'base/context.dart'; import 'base/os.dart'; +import 'base/platform.dart'; import 'base/utils.dart'; import 'globals.dart'; import 'version.dart'; -// TODO(devoncarew): We'll want to find a way to send (sanitized) command parameters. - const String _kFlutterUA = 'UA-67589403-6'; Usage get flutterUsage => Usage.instance; @@ -25,10 +24,14 @@ class Usage { final String version = versionOverride ?? FlutterVersion.getVersionString(whitelistBranchName: true); _analytics = new AnalyticsIO(_kFlutterUA, settingsName, version); - // Report a more detailed OS version string than package:usage does by - // default. Also, send the branch name as the "channel". - _analytics.setSessionValue('dimension1', os.name); - _analytics.setSessionValue('dimension2', FlutterVersion.getBranchName(whitelistBranchName: true)); + // Report a more detailed OS version string than package:usage does by default. + _analytics.setSessionValue('cd1', os.name); + // Send the branch name as the "channel". + _analytics.setSessionValue('cd2', FlutterVersion.getBranchName(whitelistBranchName: true)); + // Record the host as the application installer ID - the context that flutter_tools is running in. + if (platform.environment.containsKey('FLUTTER_HOST')) { + _analytics.setSessionValue('aiid', platform.environment['FLUTTER_HOST']); + } bool runningOnCI = false; diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml index dfb3e9410306e..5424aa009ab74 100644 --- a/packages/flutter_tools/pubspec.yaml +++ b/packages/flutter_tools/pubspec.yaml @@ -24,7 +24,7 @@ dependencies: process: 2.0.3 quiver: ^0.24.0 stack_trace: ^1.4.0 - usage: ^3.0.1 + usage: ^3.1.1 vm_service_client: '0.2.2+4' web_socket_channel: ^1.0.4 xml: ^2.4.1 diff --git a/packages/flutter_tools/templates/create/android-java.tmpl/build.gradle b/packages/flutter_tools/templates/create/android-java.tmpl/build.gradle index f5004b90712c7..879b8cabd7e4a 100644 --- a/packages/flutter_tools/templates/create/android-java.tmpl/build.gradle +++ b/packages/flutter_tools/templates/create/android-java.tmpl/build.gradle @@ -4,7 +4,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:2.2.3' + classpath 'com.android.tools.build:gradle:2.3.3' } } @@ -26,7 +26,3 @@ subprojects { task clean(type: Delete) { delete rootProject.buildDir } - -task wrapper(type: Wrapper) { - gradleVersion = '2.14.1' -} diff --git a/packages/flutter_tools/templates/create/android-kotlin.tmpl/build.gradle b/packages/flutter_tools/templates/create/android-kotlin.tmpl/build.gradle index b22b7b7dba140..27a170c24be1b 100644 --- a/packages/flutter_tools/templates/create/android-kotlin.tmpl/build.gradle +++ b/packages/flutter_tools/templates/create/android-kotlin.tmpl/build.gradle @@ -4,7 +4,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:2.2.3' + classpath 'com.android.tools.build:gradle:2.3.3' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.1.2-4' } } @@ -27,7 +27,3 @@ subprojects { task clean(type: Delete) { delete rootProject.buildDir } - -task wrapper(type: Wrapper) { - gradleVersion = '2.14.1' -} diff --git a/packages/flutter_tools/templates/create/android.tmpl/.gitignore b/packages/flutter_tools/templates/create/android.tmpl/.gitignore index 1fd9325cac44a..1658458c92451 100644 --- a/packages/flutter_tools/templates/create/android.tmpl/.gitignore +++ b/packages/flutter_tools/templates/create/android.tmpl/.gitignore @@ -7,7 +7,3 @@ /build /captures GeneratedPluginRegistrant.java - -/gradle -/gradlew -/gradlew.bat diff --git a/packages/flutter_tools/templates/create/android.tmpl/gradle/wrapper/gradle-wrapper.properties b/packages/flutter_tools/templates/create/android.tmpl/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000000..45e7f14e952d8 --- /dev/null +++ b/packages/flutter_tools/templates/create/android.tmpl/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-3.3-all.zip diff --git a/packages/flutter_tools/templates/create/pubspec.yaml.tmpl b/packages/flutter_tools/templates/create/pubspec.yaml.tmpl index d3c39f307f922..90255ce96d73b 100644 --- a/packages/flutter_tools/templates/create/pubspec.yaml.tmpl +++ b/packages/flutter_tools/templates/create/pubspec.yaml.tmpl @@ -25,16 +25,18 @@ flutter: # the Icons class. uses-material-design: true - # To add assets to your application, add an assets section here, in - # this "flutter" section, as in: + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.io/assets-and-images/. + # To add assets from package dependencies, first ensure the asset # is in the lib/ directory of the dependency. Then, # refer to the asset with a path prefixed with - # `packages/PACKAGE_NAME/`. Note: the `lib/` is implied, do not + # `packages/PACKAGE_NAME/`. The `lib/` is implied, do not # include `lib/` in the asset path. # # Here is an example: diff --git a/packages/flutter_tools/templates/plugin/android-java.tmpl/build.gradle.tmpl b/packages/flutter_tools/templates/plugin/android-java.tmpl/build.gradle.tmpl index a67e4e94021b2..5ad2e823fd128 100644 --- a/packages/flutter_tools/templates/plugin/android-java.tmpl/build.gradle.tmpl +++ b/packages/flutter_tools/templates/plugin/android-java.tmpl/build.gradle.tmpl @@ -7,7 +7,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:2.3.0' + classpath 'com.android.tools.build:gradle:2.3.3' } } diff --git a/packages/flutter_tools/templates/plugin/android-kotlin.tmpl/build.gradle.tmpl b/packages/flutter_tools/templates/plugin/android-kotlin.tmpl/build.gradle.tmpl index 9d3eecd8a0e84..02514fd93c166 100644 --- a/packages/flutter_tools/templates/plugin/android-kotlin.tmpl/build.gradle.tmpl +++ b/packages/flutter_tools/templates/plugin/android-kotlin.tmpl/build.gradle.tmpl @@ -7,7 +7,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:2.3.0' + classpath 'com.android.tools.build:gradle:2.3.3' } } diff --git a/packages/flutter_tools/templates/plugin/android.tmpl/.gitignore b/packages/flutter_tools/templates/plugin/android.tmpl/.gitignore index 5c4ef82869b58..c6cbe562a4272 100644 --- a/packages/flutter_tools/templates/plugin/android.tmpl/.gitignore +++ b/packages/flutter_tools/templates/plugin/android.tmpl/.gitignore @@ -6,7 +6,3 @@ .DS_Store /build /captures - -/gradle -/gradlew -/gradlew.bat diff --git a/packages/flutter_tools/templates/plugin/android.tmpl/gradle/wrapper/gradle-wrapper.properties b/packages/flutter_tools/templates/plugin/android.tmpl/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000000..45e7f14e952d8 --- /dev/null +++ b/packages/flutter_tools/templates/plugin/android.tmpl/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-3.3-all.zip diff --git a/packages/flutter_tools/test/asset_bundle_test.dart b/packages/flutter_tools/test/asset_bundle_test.dart index a0e6ba3291399..0da197e82ccc4 100644 --- a/packages/flutter_tools/test/asset_bundle_test.dart +++ b/packages/flutter_tools/test/asset_bundle_test.dart @@ -3,25 +3,20 @@ // found in the LICENSE file. import 'dart:convert'; + +import 'package:file/file.dart'; + import 'package:flutter_tools/src/asset.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/devfs.dart'; + import 'package:test/test.dart'; import 'src/common.dart'; +import 'src/context.dart'; void main() { - // Create a temporary directory and write a single file into it. - final FileSystem fs = const LocalFileSystem(); - final Directory tempDir = fs.systemTempDirectory.createTempSync(); - final String projectRoot = tempDir.path; - final String assetPath = 'banana.txt'; - final String assetContents = 'banana'; - final File tempFile = fs.file(fs.path.join(projectRoot, assetPath)); - tempFile.parent.createSync(recursive: true); - tempFile.writeAsBytesSync(UTF8.encode(assetContents)); - setUpAll(() { Cache.flutterRoot = getFlutterRoot(); }); @@ -56,7 +51,17 @@ void main() { expect(archivePaths[0], 'apple.txt'); expect(archivePaths[1], 'packages/flutter_gallery_assets/shrine/products/heels.png'); }); - test('file contents', () async { + + testUsingContext('file contents', () async { + // Create a temporary directory and write a single file into it. + final Directory tempDir = fs.systemTempDirectory.createTempSync(); + final String projectRoot = tempDir.path; + final String assetPath = 'banana.txt'; + final String assetContents = 'banana'; + final File tempFile = fs.file(fs.path.join(projectRoot, assetPath)); + tempFile.parent.createSync(recursive: true); + tempFile.writeAsBytesSync(UTF8.encode(assetContents)); + final AssetBundle ab = new AssetBundle.fixed(projectRoot, assetPath); expect(ab.entries, isNotEmpty); expect(ab.entries.length, 1); @@ -64,6 +69,8 @@ void main() { final DevFSContent content = ab.entries[archivePath]; expect(archivePath, assetPath); expect(assetContents, UTF8.decode(await content.contentsAsBytes())); + }, overrides: { + FileSystem: () => const LocalFileSystem(), }); }); @@ -74,4 +81,5 @@ void main() { expect(ab.entries.length, greaterThan(0)); }); }); + } diff --git a/packages/flutter_tools/test/asset_bundle_variant_test.dart b/packages/flutter_tools/test/asset_bundle_variant_test.dart new file mode 100644 index 0000000000000..17c60a5b18b3f --- /dev/null +++ b/packages/flutter_tools/test/asset_bundle_variant_test.dart @@ -0,0 +1,78 @@ +// Copyright 2016 The Chromium 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:convert'; + +import 'package:file/file.dart'; +import 'package:file/memory.dart'; + +import 'package:flutter_tools/src/asset.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/cache.dart'; + +import 'package:test/test.dart'; + +import 'src/common.dart'; +import 'src/context.dart'; + +void main() { + group('AssetBundle asset variants', () { + testUsingContext('main asset and variants', () async { + // Setting flutterRoot here so that it picks up the MemoryFileSystem's + // path separator. + Cache.flutterRoot = getFlutterRoot(); + + fs.file("pubspec.yaml") + ..createSync() + ..writeAsStringSync( +''' +name: test +dependencies: + flutter: + sdk: flutter +flutter: + assets: + - a/b/c/foo +''' + ); + fs.file(".packages")..createSync(); + + final List assets = [ + 'a/b/c/foo', + 'a/b/c/var1/foo', + 'a/b/c/var2/foo', + 'a/b/c/var3/foo', + ]; + for (String asset in assets) { + fs.file(asset) + ..createSync(recursive: true) + ..writeAsStringSync(asset); + } + + AssetBundle bundle = new AssetBundle(); + await bundle.build(manifestPath: 'pubspec.yaml'); + + // The main asset file, /a/b/c/foo, and its variants exist. + for (String asset in assets) { + expect(bundle.entries.containsKey(asset), true); + expect(UTF8.decode(await bundle.entries[asset].contentsAsBytes()), asset); + } + + fs.file('/a/b/c/foo').deleteSync(); + bundle = new AssetBundle(); + await bundle.build(manifestPath: 'pubspec.yaml'); + + // Now the main asset file, /a/b/c/foo, does not exist. This is OK because + // the /a/b/c/*/foo variants do exist. + expect(bundle.entries.containsKey('/a/b/c/foo'), false); + for (String asset in assets.skip(1)) { + expect(bundle.entries.containsKey(asset), true); + expect(UTF8.decode(await bundle.entries[asset].contentsAsBytes()), asset); + } + }, overrides: { + FileSystem: () => new MemoryFileSystem(), + }); + + }); +} diff --git a/packages/flutter_tools/test/cache_test.dart b/packages/flutter_tools/test/cache_test.dart index f4b4509296435..b921260f2327f 100644 --- a/packages/flutter_tools/test/cache_test.dart +++ b/packages/flutter_tools/test/cache_test.dart @@ -48,6 +48,34 @@ void main() { Platform: () => new FakePlatform()..environment = {'FLUTTER_ALREADY_LOCKED': 'true'}, }); }); + group('Cache', () { + test('should not be up to date, if some cached artifact is not', () { + final CachedArtifact artifact1 = new MockCachedArtifact(); + final CachedArtifact artifact2 = new MockCachedArtifact(); + when(artifact1.isUpToDate()).thenReturn(true); + when(artifact2.isUpToDate()).thenReturn(false); + final Cache cache = new Cache(artifacts: [artifact1, artifact2]); + expect(cache.isUpToDate(), isFalse); + }); + test('should be up to date, if all cached artifacts are', () { + final CachedArtifact artifact1 = new MockCachedArtifact(); + final CachedArtifact artifact2 = new MockCachedArtifact(); + when(artifact1.isUpToDate()).thenReturn(true); + when(artifact2.isUpToDate()).thenReturn(true); + final Cache cache = new Cache(artifacts: [artifact1, artifact2]); + expect(cache.isUpToDate(), isTrue); + }); + test('should update cached artifacts which are not up to date', () async { + final CachedArtifact artifact1 = new MockCachedArtifact(); + final CachedArtifact artifact2 = new MockCachedArtifact(); + when(artifact1.isUpToDate()).thenReturn(true); + when(artifact2.isUpToDate()).thenReturn(false); + final Cache cache = new Cache(artifacts: [artifact1, artifact2]); + await cache.updateAll(); + verifyNever(artifact1.update()); + verify(artifact2.update()); + }); + }); } class MockFileSystem extends MemoryFileSystem { @@ -65,3 +93,4 @@ class MockFile extends Mock implements File { } class MockRandomAccessFile extends Mock implements RandomAccessFile {} +class MockCachedArtifact extends Mock implements CachedArtifact {} diff --git a/packages/flutter_tools/test/commands/fuchsia_reload_test.dart b/packages/flutter_tools/test/commands/fuchsia_reload_test.dart new file mode 100644 index 0000000000000..b182a7c9faea9 --- /dev/null +++ b/packages/flutter_tools/test/commands/fuchsia_reload_test.dart @@ -0,0 +1,47 @@ +// Copyright 2017 The Chromium 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:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_tools/src/commands/fuchsia_reload.dart'; +import 'package:mockito/mockito.dart'; +import 'package:process/process.dart'; +import 'package:test/test.dart'; + +import '../src/context.dart'; + +void main() { + group('FuchsiaDeviceCommandRunner', () { + testUsingContext('a test', () async { + final FuchsiaDeviceCommandRunner commandRunner = + new FuchsiaDeviceCommandRunner('8.8.9.9', + '~/fuchsia', + 'release-x86-64'); + final List ports = await commandRunner.run('ls /tmp'); + expect(ports, hasLength(3)); + expect(ports[0], equals('1234')); + expect(ports[1], equals('5678')); + expect(ports[2], equals('5')); + }, overrides: { + ProcessManager: () => new MockProcessManager(), + }); + }); +} + +class MockProcessManager extends Mock implements ProcessManager { + @override + Future run( + List command, { + String workingDirectory, + Map environment, + bool includeParentEnvironment: true, + bool runInShell: false, + Encoding stdoutEncoding: SYSTEM_ENCODING, + Encoding stderrEncoding: SYSTEM_ENCODING, + }) async { + return new ProcessResult(0, 0, '1234\n5678\n5', ''); + } +} diff --git a/packages/flutter_tools/test/devfs_test.dart b/packages/flutter_tools/test/devfs_test.dart index 34030cc28467a..f931e9b2f1eb1 100644 --- a/packages/flutter_tools/test/devfs_test.dart +++ b/packages/flutter_tools/test/devfs_test.dart @@ -13,6 +13,7 @@ import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/devfs.dart'; import 'package:flutter_tools/src/vmservice.dart'; +import 'package:json_rpc_2/json_rpc_2.dart' as rpc; import 'package:test/test.dart'; import 'src/common.dart'; @@ -335,7 +336,34 @@ void main() { testUsingContext('delete dev file system', () async { expect(vmService.messages, isEmpty, reason: 'prior test timeout'); await devFS.destroy(); - vmService.expectMessages(['_deleteDevFS {fsName: test}']); + vmService.expectMessages(['destroy test']); + expect(devFS.assetPathsToEvict, isEmpty); + }, overrides: { + FileSystem: () => fs, + }); + + testUsingContext('cleanup preexisting file system', () async { + // simulate workspace + final File file = fs.file(fs.path.join(basePath, filePath)); + await file.parent.create(recursive: true); + file.writeAsBytesSync([1, 2, 3]); + + // simulate package + await _createPackage(fs, 'somepkg', 'somefile.txt'); + + devFS = new DevFS(vmService, 'test', tempDir); + await devFS.create(); + vmService.expectMessages(['create test']); + expect(devFS.assetPathsToEvict, isEmpty); + + // Try to create again. + await devFS.create(); + vmService.expectMessages(['create test', 'destroy test', 'create test']); + expect(devFS.assetPathsToEvict, isEmpty); + + // Really destroy. + await devFS.destroy(); + vmService.expectMessages(['destroy test']); expect(devFS.assetPathsToEvict, isEmpty); }, overrides: { FileSystem: () => fs, @@ -390,15 +418,29 @@ class MockVMService extends BasicMock implements VMService { class MockVM implements VM { final MockVMService _service; final Uri _baseUri = Uri.parse('file:///tmp/devfs/test'); + bool _devFSExists = false; + + static const int kFileSystemAlreadyExists = 1001; MockVM(this._service); @override Future> createDevFS(String fsName) async { _service.messages.add('create $fsName'); + if (_devFSExists) { + throw new rpc.RpcException(kFileSystemAlreadyExists, 'File system already exists'); + } + _devFSExists = true; return {'uri': '$_baseUri'}; } + @override + Future> deleteDevFS(String fsName) async { + _service.messages.add('destroy $fsName'); + _devFSExists = false; + return {'type': 'Success'}; + } + @override Future> invokeRpcRaw(String method, { Map params: const {}, diff --git a/packages/flutter_tools/test/ios/code_signing_test.dart b/packages/flutter_tools/test/ios/code_signing_test.dart index db1286ecae679..2982432d2684e 100644 --- a/packages/flutter_tools/test/ios/code_signing_test.dart +++ b/packages/flutter_tools/test/ios/code_signing_test.dart @@ -7,9 +7,11 @@ import 'dart:convert'; import 'package:mockito/mockito.dart'; import 'package:flutter_tools/src/application_package.dart'; import 'package:flutter_tools/src/base/common.dart'; +import 'package:flutter_tools/src/base/config.dart'; import 'package:flutter_tools/src/base/io.dart'; import 'package:flutter_tools/src/base/terminal.dart'; import 'package:flutter_tools/src/ios/code_signing.dart'; +import 'package:flutter_tools/src/globals.dart'; import 'package:process/process.dart'; import 'package:test/test.dart'; @@ -18,11 +20,13 @@ import '../src/context.dart'; void main() { group('Auto signing', () { ProcessManager mockProcessManager; + Config mockConfig; BuildableIOSApp app; AnsiTerminal testTerminal; setUp(() { mockProcessManager = new MockProcessManager(); + mockConfig = new MockConfig(); testTerminal = new TestTerminal(); app = new BuildableIOSApp( projectBundleId: 'test.app', @@ -198,11 +202,79 @@ void main() { expect(testLogger.errorText, isEmpty); verify(mockOpenSslStdIn.write('This is a mock certificate')); expect(developmentTeam, '4444DDDD44'); + + verify(config.setValue('ios-signing-cert', 'iPhone Developer: Profile 3 (3333CCCC33)')); }, overrides: { ProcessManager: () => mockProcessManager, + Config: () => mockConfig, AnsiTerminal: () => testTerminal, }); + + testUsingContext('Test saved certificate used', () async { + when(mockProcessManager.runSync(['which', 'security'])) + .thenReturn(exitsHappy); + when(mockProcessManager.runSync(['which', 'openssl'])) + .thenReturn(exitsHappy); + when(mockProcessManager.runSync( + argThat(contains('find-identity')), environment: any, workingDirectory: any, + )).thenReturn(new ProcessResult( + 1, // pid + 0, // exitCode + ''' +1) 86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 "iPhone Developer: Profile 1 (1111AAAA11)" +2) da4b9237bacccdf19c0760cab7aec4a8359010b0 "iPhone Developer: Profile 2 (2222BBBB22)" +3) 5bf1fd927dfb8679496a2e6cf00cbe50c1c87145 "iPhone Developer: Profile 3 (3333CCCC33)" + 3 valid identities found''', + '' + )); + when(mockProcessManager.runSync( + ['security', 'find-certificate', '-c', '3333CCCC33', '-p'], + environment: any, + workingDirectory: any, + )).thenReturn(new ProcessResult( + 1, // pid + 0, // exitCode + 'This is a mock certificate', + '', + )); + + final MockProcess mockOpenSslProcess = new MockProcess(); + final MockStdIn mockOpenSslStdIn = new MockStdIn(); + final MockStream mockOpenSslStdErr = new MockStream(); + + when(mockProcessManager.start( + argThat(contains('openssl')), environment: any, workingDirectory: any, + )).thenReturn(new Future.value(mockOpenSslProcess)); + + when(mockOpenSslProcess.stdin).thenReturn(mockOpenSslStdIn); + when(mockOpenSslProcess.stdout).thenReturn(new Stream>.fromFuture( + new Future>.value(UTF8.encode( + 'subject= /CN=iPhone Developer: Profile 3 (3333CCCC33)/OU=4444DDDD44/O=My Team/C=US' + )) + )); + when(mockOpenSslProcess.stderr).thenReturn(mockOpenSslStdErr); + when(mockOpenSslProcess.exitCode).thenReturn(0); + when(mockConfig.getValue('ios-signing-cert')).thenReturn('iPhone Developer: Profile 3 (3333CCCC33)'); + + final String developmentTeam = await getCodeSigningIdentityDevelopmentTeam(app); + + expect( + testLogger.statusText, + contains('Found saved certificate choice "iPhone Developer: Profile 3 (3333CCCC33)". To clear, use "flutter config"') + ); + expect( + testLogger.statusText, + contains('Signing iOS app for device deployment using developer identity: "iPhone Developer: Profile 3 (3333CCCC33)"') + ); + expect(testLogger.errorText, isEmpty); + verify(mockOpenSslStdIn.write('This is a mock certificate')); + expect(developmentTeam, '4444DDDD44'); + }, + overrides: { + ProcessManager: () => mockProcessManager, + Config: () => mockConfig, + }); }); } @@ -224,6 +296,7 @@ class MockProcessManager extends Mock implements ProcessManager {} class MockProcess extends Mock implements Process {} class MockStream extends Mock implements Stream> {} class MockStdIn extends Mock implements IOSink {} +class MockConfig extends Mock implements Config {} Stream mockTerminalStdInStream; diff --git a/packages/flutter_tools/test/ios/mac_test.dart b/packages/flutter_tools/test/ios/mac_test.dart index 69d8dc2947b28..fbc5603fcb7cc 100644 --- a/packages/flutter_tools/test/ios/mac_test.dart +++ b/packages/flutter_tools/test/ios/mac_test.dart @@ -7,13 +7,14 @@ import 'dart:async'; import 'package:file/file.dart'; import 'package:flutter_tools/src/application_package.dart'; import 'package:flutter_tools/src/base/file_system.dart'; -import 'package:flutter_tools/src/base/io.dart' show ProcessResult; +import 'package:flutter_tools/src/base/io.dart' show ProcessException, ProcessResult; import 'package:flutter_tools/src/ios/mac.dart'; import 'package:mockito/mockito.dart'; import 'package:platform/platform.dart'; import 'package:process/process.dart'; import 'package:test/test.dart'; +import '../src/common.dart'; import '../src/context.dart'; class MockProcessManager extends Mock implements ProcessManager {} @@ -65,6 +66,97 @@ void main() { }); }); + group('Xcode', () { + MockProcessManager mockProcessManager; + Xcode xcode; + + setUp(() { + mockProcessManager = new MockProcessManager(); + xcode = new Xcode(); + }); + + testUsingContext('xcodeSelectPath returns null when xcode-select is not installed', () { + when(mockProcessManager.runSync(['/usr/bin/xcode-select', '--print-path'])) + .thenThrow(const ProcessException('/usr/bin/xcode-select', const ['--print-path'])); + expect(xcode.xcodeSelectPath, isNull); + }, overrides: { + ProcessManager: () => mockProcessManager, + }); + + testUsingContext('xcodeSelectPath returns path when xcode-select is installed', () { + final String xcodePath = '/Applications/Xcode8.0.app/Contents/Developer'; + when(mockProcessManager.runSync(['/usr/bin/xcode-select', '--print-path'])) + .thenReturn(new ProcessResult(1, 0, xcodePath, '')); + expect(xcode.xcodeSelectPath, xcodePath); + }, overrides: { + ProcessManager: () => mockProcessManager, + }); + + testUsingContext('xcodeVersionText returns null when xcodebuild is not installed', () { + when(mockProcessManager.runSync(['/usr/bin/xcodebuild', '-version'])) + .thenThrow(const ProcessException('/usr/bin/xcodebuild', const ['-version'])); + expect(xcode.xcodeVersionText, isNull); + }, overrides: { + ProcessManager: () => mockProcessManager, + }); + + testUsingContext('xcodeVersionText returns null when xcodebuild is not installed', () { + when(mockProcessManager.runSync(['/usr/bin/xcodebuild', '-version'])) + .thenReturn(new ProcessResult(1, 0, 'Xcode 8.3.3\nBuild version 8E3004b', '')); + expect(xcode.xcodeVersionText, 'Xcode 8.3.3, Build version 8E3004b'); + }, overrides: { + ProcessManager: () => mockProcessManager, + }); + + testUsingContext('eulaSigned is false when clang is not installed', () { + when(mockProcessManager.runSync(['/usr/bin/xcrun', 'clang'])) + .thenThrow(const ProcessException('/usr/bin/xcrun', const ['clang'])); + expect(xcode.eulaSigned, isFalse); + }, overrides: { + ProcessManager: () => mockProcessManager, + }); + + testUsingContext('eulaSigned is false when clang output indicates EULA not yet accepted', () { + when(mockProcessManager.runSync(['/usr/bin/xcrun', 'clang'])) + .thenReturn(new ProcessResult(1, 1, '', 'Xcode EULA has not been accepted.\nLaunch Xcode and accept the license.')); + expect(xcode.eulaSigned, isFalse); + }, overrides: { + ProcessManager: () => mockProcessManager, + }); + + testUsingContext('eulaSigned is true when clang output indicates EULA has been accepted', () { + when(mockProcessManager.runSync(['/usr/bin/xcrun', 'clang'])) + .thenReturn(new ProcessResult(1, 1, '', 'clang: error: no input files')); + expect(xcode.eulaSigned, isTrue); + }, overrides: { + ProcessManager: () => mockProcessManager, + }); + + testUsingContext('getAvailableDevices throws ToolExit when instruments is not installed', () async { + when(mockProcessManager.run(['/usr/bin/instruments', '-s', 'devices'])) + .thenThrow(const ProcessException('/usr/bin/instruments', const ['-s', 'devices'])); + expect(() async => await xcode.getAvailableDevices(), throwsToolExit()); + }, overrides: { + ProcessManager: () => mockProcessManager, + }); + + testUsingContext('getAvailableDevices throws ToolExit when instruments returns non-zero', () async { + when(mockProcessManager.run(['/usr/bin/instruments', '-s', 'devices'])) + .thenReturn(new ProcessResult(1, 1, '', 'Sad today')); + expect(() async => await xcode.getAvailableDevices(), throwsToolExit()); + }, overrides: { + ProcessManager: () => mockProcessManager, + }); + + testUsingContext('getAvailableDevices returns instruments output when installed', () async { + when(mockProcessManager.run(['/usr/bin/instruments', '-s', 'devices'])) + .thenReturn(new ProcessResult(1, 0, 'Known Devices:\niPhone 6s (10.3.3) [foo]', '')); + expect(await xcode.getAvailableDevices(), 'Known Devices:\niPhone 6s (10.3.3) [foo]'); + }, overrides: { + ProcessManager: () => mockProcessManager, + }); + }); + group('Diagnose Xcode build failure', () { BuildableIOSApp app;