Hot reload UI polish (#5193)

* General improvoments to the loader app:
   * Show a message after 8 seconds if no connection comes in.
   * Show a progress bar as files are being uploaded.
   * Hide the spinner just before launching the application.

* General improvements to the "flutter run" UI:
   * Add "?" key as a silent alias for "h".
   * Make the help text bold so it doesn't get mixed with the logs.
   * Make "R" do a cold restart when hot reload is enabled.

* Supporting features and bug fixes:
   * Add support for string service extensions.

* Other bug fixes:
   * Expose debugDumpRenderTree() outside debug mode.
   * Logger.supportsColor was missing a getter.
   * Mention in the usage docs that --hot requires --resident.
   * Trivial style fixes.
diff --git a/packages/flutter/bin/loader/loader_app.dart b/packages/flutter/bin/loader/loader_app.dart
index 5afac13..ad2ec4e 100644
--- a/packages/flutter/bin/loader/loader_app.dart
+++ b/packages/flutter/bin/loader/loader_app.dart
@@ -1,19 +1,104 @@
+import 'dart:async';
+
 import 'package:flutter/material.dart';
 
+String message = 'Flutter Debug Loader';
+String explanation = 'Please stand by...';
+double progress = 0.0;
+double progressMax = 0.0;
+StateSetter setState = (VoidCallback fn) => fn();
+Timer connectionTimeout;
+
 void main() {
-  runApp(new MaterialApp(
-      title: 'Flutter Initial Load',
+  new LoaderBinding();
+  runApp(
+    new MaterialApp(
+      title: 'Flutter Debug Loader',
+      debugShowCheckedModeBanner: false,
       home: new Scaffold(
-        body: new Column(
-            mainAxisAlignment: MainAxisAlignment.center,
-            children: <Widget>[
-              new Text('Loading application onto device...',
-                       style: new TextStyle(fontSize: 24.0)),
-              new CircularProgressIndicator(value: null)
-            ]
+        body: new StatefulBuilder(
+          builder: (BuildContext context, StateSetter setStateRef) {
+            setState = setStateRef;
+            return new Column(
+              children: <Widget>[
+                new Flexible(
+                  child: new Container() // TODO(ianh): replace this with our logo in a Center box
+                ),
+                new Flexible(
+                  child: new Builder(
+                    builder: (BuildContext context) {
+                      List<Widget> children = <Widget>[];
+                      children.add(new Text(
+                        message,
+                        style: new TextStyle(fontSize: 24.0),
+                        textAlign: TextAlign.center
+                      ));
+                      if (progressMax >= 0.0) {
+                        children.add(new SizedBox(height: 18.0));
+                        children.add(new Center(child: new CircularProgressIndicator(value: progressMax > 0 ? progress / progressMax : null)));
+                      }
+                      return new Block(children: children);
+                    }
+                  )
+                ),
+                new Flexible(
+                  child: new Block(
+                    padding: new EdgeInsets.symmetric(horizontal: 16.0),
+                    children: <Widget>[ new Text(explanation, textAlign: TextAlign.center) ]
+                  )
+                ),
+              ]
+            );
+          }
         )
       )
     )
   );
+  connectionTimeout = new Timer(const Duration(seconds: 8), () {
+    setState(() {
+      explanation =
+        'This is a hot-reload-enabled debug-mode Flutter application. '
+        'To launch this application, please use the "flutter run" command. '
+        'To be able to launch a Flutter application in debug mode from the '
+        'device, please use "flutter run --no-hot". To install a release '
+        'mode build of this application on your device, use "flutter install".';
+      progressMax = -1.0;
+    });
+  });
 }
 
+class LoaderBinding extends WidgetsFlutterBinding {
+  @override
+  void initServiceExtensions() {
+    super.initServiceExtensions();
+    registerStringServiceExtension(
+      name: 'loaderShowMessage',
+      getter: () => message,
+      setter: (String value) {
+        connectionTimeout?.cancel();
+        connectionTimeout = null;
+        setState(() {
+          message = value;
+        });
+      }
+    );
+    registerNumericServiceExtension(
+      name: 'loaderSetProgress',
+      getter: () => progress,
+      setter: (double value) {
+        setState(() {
+          progress = value;
+        });
+      }
+    );
+    registerNumericServiceExtension(
+      name: 'loaderSetProgressMax',
+      getter: () => progressMax,
+      setter: (double value) {
+        setState(() {
+          progressMax = value;
+        });
+      }
+    );
+  }
+}
\ No newline at end of file
diff --git a/packages/flutter/lib/src/foundation/binding.dart b/packages/flutter/lib/src/foundation/binding.dart
index 79df9cc..bb9b3a8 100644
--- a/packages/flutter/lib/src/foundation/binding.dart
+++ b/packages/flutter/lib/src/foundation/binding.dart
@@ -203,6 +203,34 @@
     );
   }
 
+  /// Registers a service extension method with the given name (full name
+  /// "ext.flutter.name"), which optionally takes a single argument with the
+  /// name "value". If the argument is omitted, the value is to be read,
+  /// otherwise it is to be set. Returns the current value.
+  ///
+  /// Calls the `getter` callback to obtain the value when
+  /// responding to the service extension method being called.
+  ///
+  /// Calls the `setter` callback with the new value when the
+  /// service extension method is called with a new value.
+  void registerStringServiceExtension({
+    @required String name,
+    @required ValueGetter<String> getter,
+    @required ValueSetter<String> setter
+  }) {
+    assert(name != null);
+    assert(getter != null);
+    assert(setter != null);
+    registerServiceExtension(
+      name: name,
+      callback: (Map<String, String> parameters) async {
+        if (parameters.containsKey('value'))
+          setter(parameters['value']);
+        return <String, dynamic>{ 'value': getter() };
+      }
+    );
+  }
+
   /// Registers a service extension method with the given name (full
   /// name "ext.flutter.name"). The given callback is called when the
   /// extension method is called. The callback must return a [Future]
diff --git a/packages/flutter/lib/src/rendering/binding.dart b/packages/flutter/lib/src/rendering/binding.dart
index 2b41a40..b20184d 100644
--- a/packages/flutter/lib/src/rendering/binding.dart
+++ b/packages/flutter/lib/src/rendering/binding.dart
@@ -56,13 +56,10 @@
       return true;
     });
 
-    assert(() {
-      registerSignalServiceExtension(
-        name: 'debugDumpRenderTree',
-        callback: debugDumpRenderTree
-      );
-      return true;
-    });
+    registerSignalServiceExtension(
+      name: 'debugDumpRenderTree',
+      callback: debugDumpRenderTree
+    );
 
     assert(() {
       // this service extension only works in checked mode
diff --git a/packages/flutter_tools/lib/src/base/logger.dart b/packages/flutter_tools/lib/src/base/logger.dart
index 42bb2d9..4e16873 100644
--- a/packages/flutter_tools/lib/src/base/logger.dart
+++ b/packages/flutter_tools/lib/src/base/logger.dart
@@ -13,6 +13,7 @@
 
   bool quiet = false;
 
+  bool get supportsColor => terminal.supportsColor;
   set supportsColor(bool value) {
     terminal.supportsColor = value;
   }
@@ -76,7 +77,7 @@
     _status?.cancel();
     _status = null;
 
-    if (terminal.supportsColor) {
+    if (supportsColor) {
       _status = new _AnsiStatus(message);
       return _status;
     } else {
diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart
index f4b5175..cd14ae2 100644
--- a/packages/flutter_tools/lib/src/commands/run.dart
+++ b/packages/flutter_tools/lib/src/commands/run.dart
@@ -63,7 +63,7 @@
     argParser.addFlag('hot',
                       negatable: false,
                       defaultsTo: false,
-                      help: 'Run with support for hot reloading.');
+                      help: 'Run with support for hot reloading. Requires resident.');
 
     // Hidden option to enable a benchmarking mode. This will run the given
     // application, measure the startup time and the app restart time, write the
diff --git a/packages/flutter_tools/lib/src/devfs.dart b/packages/flutter_tools/lib/src/devfs.dart
index c46fac9..2d4e4f0 100644
--- a/packages/flutter_tools/lib/src/devfs.dart
+++ b/packages/flutter_tools/lib/src/devfs.dart
@@ -13,6 +13,8 @@
 import 'globals.dart';
 import 'observatory.dart';
 
+typedef void DevFSProgressReporter(int progress, int max);
+
 // A file that has been added to a DevFS.
 class DevFSEntry {
   DevFSEntry(this.devicePath, this.file)
@@ -178,7 +180,7 @@
     return await _operations.destroy(fsName);
   }
 
-  Future<dynamic> update([AssetBundle bundle = null]) async {
+  Future<dynamic> update({ DevFSProgressReporter progressReporter, AssetBundle bundle }) async {
     _bytes = 0;
     // Mark all entries as not seen.
     _entries.forEach((String path, DevFSEntry entry) {
@@ -203,9 +205,7 @@
         if (_syncDirectory(directory,
                            directoryName: 'packages/$packageName',
                            recursive: true)) {
-          if (sb == null) {
-            sb = new StringBuffer();
-          }
+          sb ??= new StringBuffer();
           sb.writeln('$packageName:packages/$packageName');
         }
       }
@@ -233,11 +233,20 @@
     // Send the assets.
     printTrace('DevFS: Waiting for sync of ${_pendingWrites.length} files '
                'to finish');
-    await Future.wait(_pendingWrites);
-    _pendingWrites.clear();
-    if (sb != null) {
-      await _operations.writeSource(fsName, '.packages', sb.toString());
+
+    if (progressReporter != null) {
+      final int max = _pendingWrites.length;
+      int complete = 0;
+      _pendingWrites.forEach((Future<dynamic> f) => f.then((dynamic v) {
+        complete += 1;
+        progressReporter(complete, max);
+      }));
     }
+    await Future.wait(_pendingWrites, eagerError: true);
+    _pendingWrites.clear();
+
+    if (sb != null)
+      await _operations.writeSource(fsName, '.packages', sb.toString());
     printTrace('DevFS: Sync finished');
     // NB: You must call flush after a printTrace if you want to be printed
     // immediately.
diff --git a/packages/flutter_tools/lib/src/observatory.dart b/packages/flutter_tools/lib/src/observatory.dart
index c04c9b9..b7d3337 100644
--- a/packages/flutter_tools/lib/src/observatory.dart
+++ b/packages/flutter_tools/lib/src/observatory.dart
@@ -9,6 +9,8 @@
 import 'package:json_rpc_2/json_rpc_2.dart' as rpc;
 import 'package:web_socket_channel/io.dart';
 
+import 'globals.dart';
+
 // TODO(johnmccutchan): Rename this class to ServiceProtocol or VmService.
 class Observatory {
   Observatory._(this.peer, this.port) {
@@ -204,6 +206,38 @@
     }).then((dynamic result) => new Response(result));
   }
 
+  // Loader page extension methods.
+
+  Future<Response> flutterLoaderShowMessage(String isolateId, String message) {
+    return peer.sendRequest('ext.flutter.loaderShowMessage', <String, dynamic>{
+      'isolateId': isolateId,
+      'value': message
+    }).then(
+      (dynamic result) => new Response(result),
+      onError: (dynamic exception) { printTrace('ext.flutter.loaderShowMessage: $exception'); }
+    );
+  }
+
+  Future<Response> flutterLoaderSetProgress(String isolateId, double progress) {
+    return peer.sendRequest('ext.flutter.loaderSetProgress', <String, dynamic>{
+      'isolateId': isolateId,
+      'loaderSetProgress': progress
+    }).then(
+      (dynamic result) => new Response(result),
+      onError: (dynamic exception) { printTrace('ext.flutter.loaderSetProgress: $exception'); }
+    );
+  }
+
+  Future<Response> flutterLoaderSetProgressMax(String isolateId, double max) {
+    return peer.sendRequest('ext.flutter.loaderSetProgressMax', <String, dynamic>{
+      'isolateId': isolateId,
+      'loaderSetProgressMax': max
+    }).then(
+      (dynamic result) => new Response(result),
+      onError: (dynamic exception) { printTrace('ext.flutter.loaderSetProgressMax: $exception'); }
+    );
+  }
+
   /// Causes the application to pick up any changed code.
   Future<Response> flutterReassemble(String isolateId) {
     return peer.sendRequest('ext.flutter.reassemble', <String, dynamic>{
diff --git a/packages/flutter_tools/lib/src/run.dart b/packages/flutter_tools/lib/src/run.dart
index 7eac9b7..1ee4eab 100644
--- a/packages/flutter_tools/lib/src/run.dart
+++ b/packages/flutter_tools/lib/src/run.dart
@@ -219,15 +219,6 @@
     if (debuggingOptions.debuggingEnabled) {
       observatory = await Observatory.connect(_result.observatoryPort);
       printTrace('Connected to observatory port: ${_result.observatoryPort}.');
-      if (hotMode && device.needsDevFS) {
-        bool result = await _updateDevFS();
-        if (!result) {
-          printError('Could not perform initial file synchronization.');
-          return 3;
-        }
-        printStatus('Running ${getDisplayPath(_mainPath)} on ${device.name}...');
-        await _launchFromDevFS(_package, _mainPath);
-      }
       observatory.populateIsolateInfo();
       observatory.onExtensionEvent.listen((Event event) {
         printTrace(event.toString());
@@ -236,6 +227,23 @@
         printTrace(event.toString());
       });
 
+      if (hotMode && device.needsDevFS) {
+        _loaderShowMessage('Connecting...', progress: 0);
+        bool result = await _updateDevFS(
+          progressReporter: (int progress, int max) {
+            _loaderShowMessage('Syncing files to device...', progress: progress, max: max);
+          }
+        );
+        if (!result) {
+          _loaderShowMessage('Failed.');
+          printError('Could not perform initial file synchronization.');
+          return 3;
+        }
+        printStatus('Running ${getDisplayPath(_mainPath)} on ${device.name}...');
+        _loaderShowMessage('Launching...');
+        await _launchFromDevFS(_package, _mainPath);
+      }
+
       if (benchmark)
         await observatory.waitFirstIsolate;
 
@@ -264,19 +272,20 @@
 
         terminal.singleCharMode = true;
         terminal.onCharInput.listen((String code) {
-          String lower = code.toLowerCase();
-
-          if (lower == 'h' || code == AnsiTerminal.KEY_F1) {
+          printStatus(''); // the key the user tapped might be on this line
+          final String lower = code.toLowerCase();
+          if (lower == 'h' || lower == '?' || code == AnsiTerminal.KEY_F1) {
             // F1, help
             _printHelp();
           } else if (lower == 'r' || code == AnsiTerminal.KEY_F5) {
-            if (hotMode) {
+            // F5, restart
+            if (hotMode && code == 'r') {
+              // lower-case 'r'
               _reloadSources();
             } else {
-              if (device.supportsRestart) {
-                // F5, restart
+              // upper-case 'r', or hot restart disabled
+              if (device.supportsRestart)
                 restart();
-              }
             }
           } else if (lower == 'q' || code == AnsiTerminal.KEY_F10) {
             // F10, exit
@@ -335,9 +344,20 @@
     observatory.flutterDebugDumpRenderTree(observatory.firstIsolateId);
   }
 
+  void _loaderShowMessage(String message, { int progress, int max }) {
+    observatory.flutterLoaderShowMessage(observatory.firstIsolateId, message);
+    if (progress != null) {
+      observatory.flutterLoaderSetProgress(observatory.firstIsolateId, progress.toDouble());
+      observatory.flutterLoaderSetProgressMax(observatory.firstIsolateId, max?.toDouble() ?? 0.0);
+    } else {
+      observatory.flutterLoaderSetProgress(observatory.firstIsolateId, 0.0);
+      observatory.flutterLoaderSetProgressMax(observatory.firstIsolateId, -1.0);
+    }
+  }
+
   DevFS _devFS;
   String _devFSProjectRootPath;
-  Future<bool> _updateDevFS() async {
+  Future<bool> _updateDevFS({ DevFSProgressReporter progressReporter }) async {
     if (_devFS == null) {
       Directory directory = Directory.current;
       _devFSProjectRootPath = directory.path;
@@ -358,7 +378,7 @@
     }
 
     Status devFSStatus = logger.startProgress('Syncing files on device...');
-    await _devFS.update();
+    await _devFS.update(progressReporter: progressReporter);
     devFSStatus.stop(showElapsedTime: true);
     printStatus('Synced ${getSizeAsMB(_devFS.bytes)} MB');
     return true;
@@ -387,9 +407,8 @@
   Future<bool> _reloadSources() async {
     if (observatory.firstIsolateId == null)
       throw 'Application isolate not found';
-    if (_devFS != null) {
+    if (_devFS != null)
       await _updateDevFS();
-    }
     Status reloadStatus = logger.startProgress('Performing hot reload');
     try {
       await observatory.reloadSources(observatory.firstIsolateId);
@@ -413,14 +432,21 @@
   }
 
   void _printHelp() {
-    String restartText = '';
-    if (hotMode) {
-      restartText = ', "r" or F5 to perform a hot reload of the app,';
-    } else if (device.supportsRestart) {
-      restartText = ', "r" or F5 to restart the app,';
+    printStatus('Type "h" or F1 for this help message. Type "q", F10, or ctrl-c to quit.', emphasis: true);
+    String hot = '';
+    String cold = '';
+    if (hotMode)
+      hot = 'Type "r" or F5 to perform a hot reload of the app';
+    if (device.supportsRestart) {
+      if (hotMode) {
+        cold = ', and "R" to cold restart the app';
+      } else {
+        cold = 'Type "r" or F5 to restart the app';
+      }
     }
-    printStatus('Type "h" or F1 for help$restartText and "q", F10, or ctrl-c to quit.');
-    printStatus('Type "w" to print the widget hierarchy of the app, and "t" for the render tree.');
+    if (hot != '' || cold != '')
+      printStatus('$hot$cold.', emphasis: true);
+    printStatus('Type "w" to print the widget hierarchy of the app, and "t" for the render tree.', emphasis: true);
   }
 
   Future<dynamic> _stopLogger() {
diff --git a/packages/flutter_tools/test/devfs_test.dart b/packages/flutter_tools/test/devfs_test.dart
index 67a8a1c..0f313e2 100644
--- a/packages/flutter_tools/test/devfs_test.dart
+++ b/packages/flutter_tools/test/devfs_test.dart
@@ -58,17 +58,17 @@
       expect(devFSOperations.contains('deleteFile test bar/foo.txt'), isTrue);
     });
     testUsingContext('add file in an asset bundle', () async {
-      await devFS.update(assetBundle);
+      await devFS.update(bundle: assetBundle);
       expect(devFSOperations.contains('writeFile test build/flx/a.txt'), isTrue);
     });
     testUsingContext('add a file to the asset bundle', () async {
       assetBundle.entries.add(new AssetBundleEntry.fromString('b.txt', ''));
-      await devFS.update(assetBundle);
+      await devFS.update(bundle: assetBundle);
       expect(devFSOperations.contains('writeFile test build/flx/b.txt'), isTrue);
     });
     testUsingContext('delete a file from the asset bundle', () async {
       assetBundle.entries.clear();
-      await devFS.update(assetBundle);
+      await devFS.update(bundle: assetBundle);
       expect(devFSOperations.contains('deleteFile test build/flx/b.txt'), isTrue);
     });
     testUsingContext('delete dev file system', () async {