Sample Catalog page/screenshot production (#10212)

diff --git a/examples/catalog/.gitignore b/examples/catalog/.gitignore
new file mode 100644
index 0000000..52bc6fb
--- /dev/null
+++ b/examples/catalog/.gitignore
@@ -0,0 +1,3 @@
+.generated/
+test_driver/screenshot.dart
+test_driver/screenshot_test.dart
diff --git a/examples/catalog/README.md b/examples/catalog/README.md
new file mode 100644
index 0000000..f841b15
--- /dev/null
+++ b/examples/catalog/README.md
@@ -0,0 +1,31 @@
+Samples Catalog
+=======
+
+A collection of sample apps that demonstrate how Flutter can be used.
+
+Each sample app is contained in a single `.dart` file and they're all found in
+the lib directory.
+
+The apps are intended to be short and easily understood. Classes that represent
+the sample's focus are at the top of the file, data and support classes follow.
+
+Each sample app contains a comment (usually at the end) which provides some
+standard documentation that also appears in the web view of the catalog.
+See the "Generating..." section below.
+
+Generating the web view of the catalog
+---------
+
+Markdown and a screenshot of each app are produced by `bin/sample_page.dart`
+and saved in the `.generated` directory. The markdown file contains
+the text taken from the Sample Catalog comment found in the app's source
+file, followed by the source code itself.
+
+This sample_page.dart command line app must be run from the examples/catalog
+directory. It relies on templates also found in the bin directory and it
+generates and executes `test_driver` apps to collect the screenshots:
+
+```
+cd examples/catalog
+dart bin/sample_page.dart
+```
diff --git a/examples/catalog/bin/sample_page.dart b/examples/catalog/bin/sample_page.dart
new file mode 100644
index 0000000..cc28273
--- /dev/null
+++ b/examples/catalog/bin/sample_page.dart
@@ -0,0 +1,203 @@
+// 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.
+
+// This application generates markdown pages and screenshots for each
+// sample app. For more information see ../README.md.
+
+import 'dart:io';
+
+class SampleError extends Error {
+  SampleError(this.message);
+  final String message;
+  @override
+  String toString() => message;
+}
+
+// Sample apps are .dart files in the lib directory which contain a block
+// comment that begins with a '/* Sample Catalog' line, and ends with a line
+// that just  contains '*/'. The following keywords may appear at the
+// beginning of lines within the comment. A keyword's value is all of
+// the following text up to the next keyword or the end of the comment,
+// sans leading and trailing whitespace.
+const String sampleCatalogKeywords = r'^Title:|^Summary:|^Description:|^Classes:|^Sample:|^See also:';
+
+Directory outputDirectory;
+Directory sampleDirectory;
+Directory testDirectory;
+Directory driverDirectory;
+String sampleTemplate;
+String screenshotTemplate;
+String screenshotDriverTemplate;
+
+void logMessage(String s) { print(s); }
+void logError(String s) { print(s); }
+
+File inputFile(String dir, String name) {
+  return new File(dir + Platform.pathSeparator + name);
+}
+
+File outputFile(String name, [Directory directory]) {
+  return new File((directory ?? outputDirectory).path + Platform.pathSeparator + name);
+}
+
+void initialize() {
+  final File sampleTemplateFile = inputFile('bin', 'sample_page.md.template');
+  final File screenshotTemplateFile = inputFile('bin', 'screenshot.dart.template');
+  final File screenshotDriverTemplateFile = inputFile('bin', 'screenshot_test.dart.template');
+
+  outputDirectory = new Directory('.generated');
+  sampleDirectory = new Directory('lib');
+  testDirectory = new Directory('test');
+  driverDirectory = new Directory('test_driver');
+  sampleTemplate = sampleTemplateFile.readAsStringSync();
+  screenshotTemplate = screenshotTemplateFile.readAsStringSync();
+  screenshotDriverTemplate = screenshotDriverTemplateFile.readAsStringSync();
+}
+
+// Return a copy of template with each occurrence of @(foo) replaced
+// by values[foo].
+String expandTemplate(String template, Map<String, String> values) {
+  // Matches @(foo), match[1] == 'foo'
+  final RegExp tokenRE = new RegExp(r'@\(([\w ]+)\)', multiLine: true);
+  return template.replaceAllMapped(tokenRE, (Match match) {
+    if (match.groupCount != 1)
+      throw new SampleError('bad template keyword $match[0]');
+    final String keyword = match[1];
+    return (values[keyword] ?? "");
+  });
+}
+
+void writeExpandedTemplate(File output, String template, Map<String, String> values) {
+  output.writeAsStringSync(expandTemplate(template, values));
+  logMessage('wrote $output');
+}
+
+class SampleGenerator {
+  SampleGenerator(this.sourceFile);
+
+  final File sourceFile;
+  String sourceCode;
+  Map<String, String> commentValues;
+
+  // If sourceFile is lib/foo.dart then sourceName is foo. The sourceName
+  // is used to create derived filenames like foo.md or foo.png.
+  String get sourceName {
+    // In /foo/bar/baz.dart, matches baz.dart, match[1] == 'baz'
+    final RegExp nameRE = new RegExp(r'(\w+)\.dart$');
+    final Match nameMatch = nameRE.firstMatch(sourceFile.path);
+    if (nameMatch.groupCount != 1)
+      throw new SampleError('bad source file name ${sourceFile.path}');
+    return nameMatch[1];
+  }
+
+  // The name of the widget class that defines this sample app, like 'FooSample'.
+  String get sampleClass => commentValues["sample"];
+
+  // The relative import path for this sample, like '../lib/foo.dart'.
+  String get importPath => '..' + Platform.pathSeparator + sourceFile.path;
+
+  // Return true if we're able to find the "Sample Catalog" comment in the
+  // sourceFile, and we're able to load its keyword/value pairs into
+  // the commentValues Map. The rest of the file's contents are saved
+  // in sourceCode.
+  bool initialize() {
+    final String contents = sourceFile.readAsStringSync();
+
+    final RegExp startRE = new RegExp(r'^/\*\s+^Sample\s+Catalog', multiLine: true);
+    final RegExp endRE = new RegExp(r'^\*/', multiLine: true);
+    final Match startMatch = startRE.firstMatch(contents);
+    if (startMatch == null)
+      return false;
+
+    final int startIndex = startMatch.end;
+    final Match endMatch = endRE.firstMatch(contents.substring(startIndex));
+    if (endMatch == null)
+      return false;
+
+    final String comment = contents.substring(startIndex, startIndex + endMatch.start);
+    sourceCode = contents.substring(0, startMatch.start) + contents.substring(startIndex + endMatch.end);
+    if (sourceCode.trim().isEmpty)
+      throw new SampleError('did not find any source code in $sourceFile');
+
+    final RegExp keywordsRE = new RegExp(sampleCatalogKeywords, multiLine: true);
+    final List<Match> keywordMatches = keywordsRE.allMatches(comment).toList();
+    // TBD: fix error generation
+    if (keywordMatches.isEmpty)
+      throw new SampleError('did not find any keywords in the Sample Catalog comment in $sourceFile');
+
+    commentValues = <String, String>{};
+    for (int i = 0; i < keywordMatches.length; i += 1) {
+      final String keyword = comment.substring(keywordMatches[i].start, keywordMatches[i].end - 1);
+      final String value = comment.substring(
+        keywordMatches[i].end,
+        i == keywordMatches.length - 1 ? null : keywordMatches[i + 1].start,
+      );
+      commentValues[keyword.toLowerCase()] = value.trim();
+    }
+    commentValues['source'] = sourceCode.trim();
+
+    return true;
+  }
+}
+
+void generate() {
+  initialize();
+
+  final List<SampleGenerator> samples = <SampleGenerator>[];
+  sampleDirectory.listSync().forEach((FileSystemEntity entity) {
+    if (entity is File && entity.path.endsWith('.dart')) {
+      final SampleGenerator sample = new SampleGenerator(entity);
+      if (sample.initialize()) { // skip files that lack the Sample Catalog comment
+        writeExpandedTemplate(
+          outputFile(sample.sourceName + '.md'),
+          sampleTemplate,
+          sample.commentValues,
+        );
+        samples.add(sample);
+      }
+    }
+  });
+
+  writeExpandedTemplate(
+    outputFile('screenshot.dart', driverDirectory),
+    screenshotTemplate,
+    <String, String>{
+      'imports': samples.map((SampleGenerator page) {
+        return "import '${page.importPath}' show ${page.sampleClass};\n";
+      }).toList().join(),
+      'widgets': samples.map((SampleGenerator sample) {
+        return 'new ${sample.sampleClass}(),\n';
+      }).toList().join(),
+    },
+  );
+
+  writeExpandedTemplate(
+    outputFile('screenshot_test.dart', driverDirectory),
+    screenshotDriverTemplate,
+    <String, String>{
+      'paths': samples.map((SampleGenerator sample) {
+        return "'${outputFile(sample.sourceName + '.png').path}'";
+      }).toList().join(',\n'),
+    },
+  );
+
+  final List<String> flutterDriveArgs = <String>['drive', 'test_driver/screenshot.dart'];
+  logMessage('Generating screenshots with: flutter ${flutterDriveArgs.join(" ")}');
+  Process.runSync('flutter', flutterDriveArgs);
+}
+
+void main(List<String> args) {
+  try {
+    generate();
+  } catch (error) {
+    logError(
+      'Error: sample_page.dart failed: $error\n'
+      'This sample_page.dart app expects to be run from the examples/catalog directory. '
+      'More information can be found in examples/catalog/README.md.'
+    );
+    exit(255);
+  }
+
+  exit(0);
+}
diff --git a/examples/catalog/bin/sample_page.md.template b/examples/catalog/bin/sample_page.md.template
new file mode 100644
index 0000000..cc937f1
--- /dev/null
+++ b/examples/catalog/bin/sample_page.md.template
@@ -0,0 +1,13 @@
+@(title)
+=============
+
+@(summary)
+
+@(description)
+
+See also:
+@(see also)
+
+```
+@(source)
+```
diff --git a/examples/catalog/bin/screenshot.dart.template b/examples/catalog/bin/screenshot.dart.template
new file mode 100644
index 0000000..b513f85
--- /dev/null
+++ b/examples/catalog/bin/screenshot.dart.template
@@ -0,0 +1,40 @@
+// This file was generated using bin/screenshot.dart.template and
+// bin/sample_page.dart. For more information see README.md.
+
+import 'package:flutter_driver/driver_extension.dart';
+import 'package:flutter/material.dart';
+
+@(imports)
+
+class SampleScreenshots extends StatefulWidget {
+  @override
+  SampleScreenshotsState createState() => new SampleScreenshotsState();
+}
+
+class SampleScreenshotsState extends State<SampleScreenshots> {
+  final List<Widget> samples = <Widget>[
+    @(widgets)
+  ];
+  int sampleIndex = 0;
+
+  @override
+  Widget build(BuildContext context) {
+    return new GestureDetector(
+      key: const ValueKey<String>('screenshotGestureDetector'),
+      behavior: HitTestBehavior.opaque,
+      onTap: () {
+        setState(() {
+          sampleIndex += 1;
+        });
+      },
+      child: new IgnorePointer(
+        child: samples[sampleIndex % samples.length],
+      ),
+    );
+  }
+}
+
+void main() {
+  enableFlutterDriverExtension();
+  runApp(new SampleScreenshots());
+}
diff --git a/examples/catalog/bin/screenshot_test.dart.template b/examples/catalog/bin/screenshot_test.dart.template
new file mode 100644
index 0000000..d25ceae
--- /dev/null
+++ b/examples/catalog/bin/screenshot_test.dart.template
@@ -0,0 +1,35 @@
+// This file was generated using bin/screenshot_test.dart.template and
+// bin/sample_page.dart. For more information see README.md.
+
+import 'dart:io';
+
+import 'package:flutter_driver/flutter_driver.dart';
+import 'package:test/test.dart';
+
+void main() {
+  group('sample screenshots', () async {
+    FlutterDriver driver;
+
+    setUpAll(() async {
+      driver = await FlutterDriver.connect();
+    });
+
+    tearDownAll(() async {
+      await driver?.close();
+    });
+
+    test('take sample screenshots', () async {
+      final List<String> paths = <String>[
+        @(paths)
+      ];
+      for (String path in paths) {
+        final List<int> pixels = await driver.screenshot();
+        final File file = new File(path);
+        await file.writeAsBytes(pixels);
+        print('wrote $file');
+        await driver.tap(find.byValueKey('screenshotGestureDetector'));
+        await driver.waitUntilNoTransientCallbacks();
+      }
+    });
+  });
+}
diff --git a/examples/catalog/lib/animated_list.dart b/examples/catalog/lib/animated_list.dart
index 4cddd42..5742ed5 100644
--- a/examples/catalog/lib/animated_list.dart
+++ b/examples/catalog/lib/animated_list.dart
@@ -2,113 +2,9 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-// A sample app that demonstrates using an AnimatedList.
-//
-// Tap an item to select it, tap it again to unselect. Tap '+' to insert at the
-// selected item, '-' to remove the selected item.
-//
-// This app includes a ListModel<E> class, a simple encapsulation of List<E>
-// that keeps an AnimatedList in sync.
-
 import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 
-/// Displays its integer item as 'item N' on a Card whose color is based on
-/// the item's value. The text is displayed in bright green if selected is true.
-/// This widget's height is based on the animation parameter, it varies
-/// from 0 to 128 as the animation varies from 0.0 to 1.0.
-class CardItem extends StatelessWidget {
-  CardItem({
-    Key key,
-    @required this.animation,
-    this.onTap,
-    @required this.item,
-    this.selected: false
-  }) : super(key: key) {
-    assert(animation != null);
-    assert(item != null && item >= 0);
-    assert(selected != null);
-  }
-
-  final Animation<double> animation;
-  final VoidCallback onTap;
-  final int item;
-  final bool selected;
-
-  @override
-  Widget build(BuildContext context) {
-    TextStyle textStyle = Theme.of(context).textTheme.display1;
-    if (selected)
-      textStyle = textStyle.copyWith(color: Colors.lightGreenAccent[400]);
-    return new Padding(
-      padding: const EdgeInsets.all(2.0),
-      child: new SizeTransition(
-        axis: Axis.vertical,
-        sizeFactor: animation,
-        child: new GestureDetector(
-          behavior: HitTestBehavior.opaque,
-          onTap: onTap,
-          child: new SizedBox(
-            height: 128.0,
-            child: new Card(
-              color: Colors.primaries[item % Colors.primaries.length],
-              child: new Center(
-                child: new Text('Item $item', style: textStyle),
-              ),
-            ),
-          ),
-        ),
-      ),
-    );
-  }
-}
-
-/// Keeps a Dart List in sync with an AnimatedList.
-///
-/// The [insert] and [removeAt] methods apply to both the internal list and the
-/// animated list that belongs to [listKey].
-///
-/// This class only exposes as much of the Dart List API as is needed by the
-/// sample app. More list methods are easily added, however methods that mutate the
-/// list must make the same changes to the animated list in terms of
-/// [AnimatedListState.insertItem] and [AnimatedList.removeItem].
-class ListModel<E> {
-  ListModel({
-    @required this.listKey,
-    @required this.removedItemBuilder,
-    Iterable<E> initialItems,
-  }) : _items =  new List<E>.from(initialItems ?? <E>[]) {
-    assert(listKey != null);
-    assert(removedItemBuilder != null);
-  }
-
-  final GlobalKey<AnimatedListState> listKey;
-  final dynamic removedItemBuilder;
-  final List<E> _items;
-
-  AnimatedListState get _animatedList => listKey.currentState;
-
-  void insert(int index, E item) {
-    _items.insert(index, item);
-    _animatedList.insertItem(index);
-  }
-
-  E removeAt(int index) {
-    final E removedItem = _items.removeAt(index);
-    if (removedItem != null) {
-      _animatedList.removeItem(index, (BuildContext context, Animation<double> animation) {
-        return removedItemBuilder(removedItem, context, animation);
-      });
-    }
-    return removedItem;
-  }
-
-  int get length => _items.length;
-  E operator [](int index) => _items[index];
-  int indexOf(E item) => _items.indexOf(item);
-}
-
-
 class AnimatedListSample extends StatefulWidget {
   @override
   _AnimatedListSampleState createState() => new _AnimatedListSampleState();
@@ -177,28 +73,125 @@
 
   @override
   Widget build(BuildContext context) {
-    return new Scaffold(
-      appBar: new AppBar(
-        title: const Text('AnimatedList'),
-        actions: <Widget>[
-          new IconButton(
-            icon: const Icon(Icons.add_circle),
-            onPressed: _insert,
-            tooltip: 'insert a new item',
+    return new MaterialApp(
+      home: new Scaffold(
+        appBar: new AppBar(
+          title: const Text('AnimatedList'),
+          actions: <Widget>[
+            new IconButton(
+              icon: const Icon(Icons.add_circle),
+              onPressed: _insert,
+              tooltip: 'insert a new item',
+            ),
+            new IconButton(
+              icon: const Icon(Icons.remove_circle),
+              onPressed: _remove,
+              tooltip: 'remove the selected item',
+            ),
+          ],
+        ),
+        body: new Padding(
+          padding: const EdgeInsets.all(16.0),
+          child: new AnimatedList(
+            key: _listKey,
+            initialItemCount: _list.length,
+            itemBuilder: _buildItem,
           ),
-          new IconButton(
-            icon: const Icon(Icons.remove_circle),
-            onPressed: _remove,
-            tooltip: 'remove the selected item',
-          ),
-        ],
+        ),
       ),
-      body: new Padding(
-        padding: const EdgeInsets.all(16.0),
-        child: new AnimatedList(
-          key: _listKey,
-          initialItemCount: _list.length,
-          itemBuilder: _buildItem,
+    );
+  }
+}
+
+/// Keeps a Dart List in sync with an AnimatedList.
+///
+/// The [insert] and [removeAt] methods apply to both the internal list and the
+/// animated list that belongs to [listKey].
+///
+/// This class only exposes as much of the Dart List API as is needed by the
+/// sample app. More list methods are easily added, however methods that mutate the
+/// list must make the same changes to the animated list in terms of
+/// [AnimatedListState.insertItem] and [AnimatedList.removeItem].
+class ListModel<E> {
+  ListModel({
+    @required this.listKey,
+    @required this.removedItemBuilder,
+    Iterable<E> initialItems,
+  }) : _items =  new List<E>.from(initialItems ?? <E>[]) {
+    assert(listKey != null);
+    assert(removedItemBuilder != null);
+  }
+
+  final GlobalKey<AnimatedListState> listKey;
+  final dynamic removedItemBuilder;
+  final List<E> _items;
+
+  AnimatedListState get _animatedList => listKey.currentState;
+
+  void insert(int index, E item) {
+    _items.insert(index, item);
+    _animatedList.insertItem(index);
+  }
+
+  E removeAt(int index) {
+    final E removedItem = _items.removeAt(index);
+    if (removedItem != null) {
+      _animatedList.removeItem(index, (BuildContext context, Animation<double> animation) {
+        return removedItemBuilder(removedItem, context, animation);
+      });
+    }
+    return removedItem;
+  }
+
+  int get length => _items.length;
+  E operator [](int index) => _items[index];
+  int indexOf(E item) => _items.indexOf(item);
+}
+
+/// Displays its integer item as 'item N' on a Card whose color is based on
+/// the item's value. The text is displayed in bright green if selected is true.
+/// This widget's height is based on the animation parameter, it varies
+/// from 0 to 128 as the animation varies from 0.0 to 1.0.
+class CardItem extends StatelessWidget {
+  CardItem({
+    Key key,
+    @required this.animation,
+    this.onTap,
+    @required this.item,
+    this.selected: false
+  }) : super(key: key) {
+    assert(animation != null);
+    assert(item != null && item >= 0);
+    assert(selected != null);
+  }
+
+  final Animation<double> animation;
+  final VoidCallback onTap;
+  final int item;
+  final bool selected;
+
+  @override
+  Widget build(BuildContext context) {
+    TextStyle textStyle = Theme.of(context).textTheme.display1;
+    if (selected)
+      textStyle = textStyle.copyWith(color: Colors.lightGreenAccent[400]);
+    return new Padding(
+      padding: const EdgeInsets.all(2.0),
+      child: new SizeTransition(
+        axis: Axis.vertical,
+        sizeFactor: animation,
+        child: new GestureDetector(
+          behavior: HitTestBehavior.opaque,
+          onTap: onTap,
+          child: new SizedBox(
+            height: 128.0,
+            child: new Card(
+              color: Colors.primaries[item % Colors.primaries.length],
+              child: new Center(
+                child: new Text('Item $item', style: textStyle),
+              ),
+            ),
+          ),
         ),
       ),
     );
@@ -206,5 +199,32 @@
 }
 
 void main() {
-  runApp(new MaterialApp(home: new AnimatedListSample()));
+  runApp(new AnimatedListSample());
 }
+
+/*
+Sample Catalog
+
+Title: AnimatedList
+
+Summary: In this app an AnimatedList displays a list of cards which stays
+in sync with an app-specific ListModel. When an item is added to or removed
+from the model, a corresponding card items animate in or out of view
+in the animated list.
+
+Description:
+Tap an item to select it, tap it again to unselect. Tap '+' to insert at the
+selected item, '-' to remove the selected item. The tap handlers add or
+remove items from a `ListModel<E>`, a simple encapsulation of `List<E>`
+that keeps the AnimatedList in sync. The list model has a GlobalKey for
+its animated list. It uses the key to call the insertItem and removeItem
+methods defined by AnimatedListState.
+
+Classes: AnimatedList, AnimatedListState
+
+Sample: AnimatedListSample
+
+See also:
+  - The "Components-Lists: Controls" section of the material design specification:
+    <https://material.io/guidelines/components/lists-controls.html#>
+*/
diff --git a/examples/catalog/lib/app_bar_bottom.dart b/examples/catalog/lib/app_bar_bottom.dart
index b30cd04..308ec837 100644
--- a/examples/catalog/lib/app_bar_bottom.dart
+++ b/examples/catalog/lib/app_bar_bottom.dart
@@ -4,17 +4,76 @@
 
 import 'package:flutter/material.dart';
 
-/// Sample Catalog: AppBar with a custom bottom widget.
-///
-/// The bottom widget's TabPageSelector displays the relative position of the
-/// selected choice. The arrow buttons in the toolbar part of the app bar
-/// select the previous or the next choice.
-///
-/// Sample classes: [AppBar], [TabController], [TabPageSelector], [Scaffold], [TabBarView].
-///
-/// See also:
-///  * The "Components-Tabs" section of the material design specification:
-///    <https://material.io/guidelines/components/tabs.html>
+class AppBarBottomSample extends StatefulWidget {
+  @override
+  _AppBarBottomSampleState createState() => new _AppBarBottomSampleState();
+}
+
+class _AppBarBottomSampleState extends State<AppBarBottomSample> with SingleTickerProviderStateMixin {
+  TabController _tabController;
+
+  @override
+  void initState() {
+    super.initState();
+    _tabController = new TabController(vsync: this, length: choices.length);
+  }
+
+  @override
+  void dispose() {
+    _tabController.dispose();
+    super.dispose();
+  }
+
+  void _nextPage(int delta) {
+    final int newIndex = _tabController.index + delta;
+    if (newIndex < 0 || newIndex >= _tabController.length)
+      return;
+    _tabController.animateTo(newIndex);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return new MaterialApp(
+      home: new Scaffold(
+        appBar: new AppBar(
+          title: const Text('AppBar Bottom Widget'),
+          leading: new IconButton(
+            tooltip: 'Previous choice',
+            icon: const Icon(Icons.arrow_back),
+            onPressed: () { _nextPage(-1); },
+          ),
+          actions: <Widget>[
+            new IconButton(
+              icon: const Icon(Icons.arrow_forward),
+              tooltip: 'Next choice',
+              onPressed: () { _nextPage(1); },
+            ),
+          ],
+          bottom: new PreferredSize(
+            preferredSize: const Size.fromHeight(48.0),
+            child: new Theme(
+              data: Theme.of(context).copyWith(accentColor: Colors.white),
+              child: new Container(
+                height: 48.0,
+                alignment: FractionalOffset.center,
+                child: new TabPageSelector(controller: _tabController),
+              ),
+            ),
+          ),
+        ),
+        body: new TabBarView(
+          controller: _tabController,
+          children: choices.map((Choice choice) {
+            return new Padding(
+              padding: const EdgeInsets.all(16.0),
+              child: new ChoiceCard(choice: choice),
+            );
+          }).toList(),
+        ),
+      ),
+    );
+  }
+}
 
 class Choice {
   const Choice({ this.title, this.icon });
@@ -55,75 +114,29 @@
   }
 }
 
-class AppBarBottomSample extends StatefulWidget {
-  @override
-  _AppBarBottomSampleState createState() => new _AppBarBottomSampleState();
-}
-
-class _AppBarBottomSampleState extends State<AppBarBottomSample> with SingleTickerProviderStateMixin {
-  TabController _tabController;
-
-  @override
-  void initState() {
-    super.initState();
-    _tabController = new TabController(vsync: this, length: choices.length);
-  }
-
-  @override
-  void dispose() {
-    _tabController.dispose();
-    super.dispose();
-  }
-
-  void _nextPage(int delta) {
-    final int newIndex = _tabController.index + delta;
-    if (newIndex < 0 || newIndex >= _tabController.length)
-      return;
-    _tabController.animateTo(newIndex);
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    return new Scaffold(
-      appBar: new AppBar(
-        title: const Text('AppBar Bottom Widget'),
-        leading: new IconButton(
-            tooltip: 'Previous choice',
-          icon: const Icon(Icons.arrow_back),
-          onPressed: () { _nextPage(-1); },
-        ),
-        actions: <Widget>[
-          new IconButton(
-            icon: const Icon(Icons.arrow_forward),
-            tooltip: 'Next choice',
-            onPressed: () { _nextPage(1); },
-          ),
-        ],
-        bottom: new PreferredSize(
-          preferredSize: const Size.fromHeight(48.0),
-          child: new Theme(
-            data: Theme.of(context).copyWith(accentColor: Colors.white),
-            child: new Container(
-              height: 48.0,
-              alignment: FractionalOffset.center,
-              child: new TabPageSelector(controller: _tabController),
-            ),
-          ),
-        ),
-      ),
-      body: new TabBarView(
-        controller: _tabController,
-        children: choices.map((Choice choice) {
-          return new Padding(
-            padding: const EdgeInsets.all(16.0),
-            child: new ChoiceCard(choice: choice),
-          );
-        }).toList(),
-      ),
-    );
-  }
-}
-
 void main() {
-  runApp(new MaterialApp(home: new AppBarBottomSample()));
+  runApp(new AppBarBottomSample());
 }
+
+/*
+Sample Catalog
+
+Title: AppBar with a custom bottom widget.
+
+Summary: The AppBar's bottom widget is often a TabBar however any widget with a
+PreferredSize can be used.
+
+Description:
+In this app, the app bar's bottom widget is a TabPageSelector
+that displays the relative position of the selected page in the app's
+TabBarView. The arrow buttons in the toolbar part of the app bar select
+the previous or the next choice.
+
+Classes: AppBar, PreferredSize, TabBarView, TabController
+
+Sample: AppBarBottomSample
+
+See also:
+  - The "Components-Tabs" section of the material design specification:
+    <https://material.io/guidelines/components/tabs.html>
+*/
diff --git a/examples/catalog/lib/basic_app_bar.dart b/examples/catalog/lib/basic_app_bar.dart
index 34823b9..ed15fd9 100644
--- a/examples/catalog/lib/basic_app_bar.dart
+++ b/examples/catalog/lib/basic_app_bar.dart
@@ -4,17 +4,57 @@
 
 import 'package:flutter/material.dart';
 
-/// Sample Catalog: Basic AppBar
-///
-/// An AppBar with a title, actions, and an overflow menu. One of the app's
-/// choices can be selected action buttons or the menu.
-///
-/// Sample classes: [AppBar], [IconButton], [PopupMenuButton], [Scaffold].
-///
-/// See also:
-///
-///  * The "Layout-Structure" section of the material design specification:
-///    <https://material.io/guidelines/layout/structure.html#structure-app-bar>
+// This app is a stateful, it tracks the user's current choice.
+class BasicAppBarSample extends StatefulWidget {
+  @override
+  _BasicAppBarSampleState createState() => new _BasicAppBarSampleState();
+}
+
+class _BasicAppBarSampleState extends State<BasicAppBarSample> {
+  Choice _selectedChoice = choices[0]; // The app's "state".
+
+  void _select(Choice choice) {
+    setState(() { // Causes the app to rebuild with the new _selectedChoice.
+      _selectedChoice = choice;
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return new MaterialApp(
+      home: new Scaffold(
+        appBar: new AppBar(
+          title: const Text('Basic AppBar'),
+          actions: <Widget>[
+            new IconButton( // action button
+              icon: new Icon(choices[0].icon),
+              onPressed: () { _select(choices[0]); },
+            ),
+            new IconButton( // action button
+              icon: new Icon(choices[1].icon),
+              onPressed: () { _select(choices[1]); },
+            ),
+            new PopupMenuButton<Choice>( // overflow menu
+              onSelected: _select,
+              itemBuilder: (BuildContext context) {
+                return choices.skip(2).map((Choice choice) {
+                  return new PopupMenuItem<Choice>(
+                    value: choice,
+                    child: new Text(choice.title),
+                  );
+                }).toList();
+              },
+            ),
+          ],
+        ),
+        body: new Padding(
+          padding: const EdgeInsets.all(16.0),
+          child: new ChoiceCard(choice: _selectedChoice),
+        ),
+      ),
+    );
+  }
+}
 
 class Choice {
   const Choice({ this.title, this.icon });
@@ -55,55 +95,28 @@
   }
 }
 
-class BasicAppBarSample extends StatefulWidget {
-  @override
-  _BasicAppBarSampleState createState() => new _BasicAppBarSampleState();
-}
-
-class _BasicAppBarSampleState extends State<BasicAppBarSample> {
-  Choice _selectedChoice = choices[0];
-
-  void _select(Choice choice) {
-    setState(() {
-      _selectedChoice = choice;
-    });
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    return new Scaffold(
-      appBar: new AppBar(
-        title: const Text('Basic AppBar'),
-        actions: <Widget>[
-          new IconButton(
-            icon: new Icon(choices[0].icon),
-            onPressed: () { _select(choices[0]); },
-          ),
-          new IconButton(
-            icon: new Icon(choices[1].icon),
-            onPressed: () { _select(choices[1]); },
-          ),
-          new PopupMenuButton<Choice>(
-            onSelected: _select,
-            itemBuilder: (BuildContext context) {
-              return choices.skip(2).map((Choice choice) {
-                return new PopupMenuItem<Choice>(
-                  value: choice,
-                  child: new Text(choice.title),
-                );
-              }).toList();
-            },
-          ),
-        ],
-      ),
-      body: new Padding(
-        padding: const EdgeInsets.all(16.0),
-        child: new ChoiceCard(choice: _selectedChoice),
-      ),
-    );
-  }
-}
-
 void main() {
-  runApp(new MaterialApp(home: new BasicAppBarSample()));
+  runApp(new BasicAppBarSample());
 }
+
+/*
+Sample Catalog
+
+Title: AppBar Basics
+
+Summary: An AppBar with a title, actions, and an overflow dropdown menu.
+One of the app's choices can be selected with an action button or the menu.
+
+Description:
+An app that displays one of a half dozen choices with an icon and a title.
+The two most common choices are available as action buttons and the remaining
+choices are included in the overflow dropdow menu.
+
+Classes: AppBar, IconButton, PopupMenuButton, Scaffold
+
+Sample: BasicAppBarSample
+
+See also:
+  - The "Layout-Structure" section of the material design specification:
+    <https://material.io/guidelines/layout/structure.html#structure-app-bar>
+*/
diff --git a/examples/catalog/lib/expansion_tile_sample.dart b/examples/catalog/lib/expansion_tile_sample.dart
index 3e639a0..2213ee1 100644
--- a/examples/catalog/lib/expansion_tile_sample.dart
+++ b/examples/catalog/lib/expansion_tile_sample.dart
@@ -4,12 +4,31 @@
 
 import 'package:flutter/material.dart';
 
+class ExpansionTileSample extends StatelessWidget {
+  @override
+  Widget build(BuildContext context) {
+    return new MaterialApp(
+      home: new Scaffold(
+        appBar: new AppBar(
+          title: const Text('ExpansionTile'),
+        ),
+        body: new ListView.builder(
+          itemBuilder: (BuildContext context, int index) => new EntryItem(data[index]),
+          itemCount: data.length,
+        ),
+      ),
+    );
+  }
+}
+
+// One entry in the multilevel list displayed by this app.
 class Entry {
   Entry(this.title, [this.children = const <Entry>[]]);
   final String title;
   final List<Entry> children;
 }
 
+// The entire multilevel list displayed by this app.
 final List<Entry> data = <Entry>[
   new Entry('Chapter A',
     <Entry>[
@@ -46,6 +65,8 @@
   ),
 ];
 
+// Displays one Entry. If the entry has children then it's displayed
+// with an ExpansionTile.
 class EntryItem extends StatelessWidget {
   EntryItem(this.entry);
 
@@ -67,21 +88,31 @@
   }
 }
 
-class ExpansionTileSample extends StatelessWidget {
-  @override
-  Widget build(BuildContext context) {
-    return new Scaffold(
-      appBar: new AppBar(
-        title: const Text('ExpansionTile'),
-      ),
-      body: new ListView.builder(
-        itemBuilder: (BuildContext context, int index) => new EntryItem(data[index]),
-        itemCount: data.length,
-      ),
-    );
-  }
+void main() {
+  runApp(new ExpansionTileSample());
 }
 
-void main() {
-  runApp(new MaterialApp(home: new ExpansionTileSample()));
-}
+/*
+Sample Catalog
+
+Title: ExpansionTile
+
+Summary: ExpansionTiles can used to produce two-level or multi-level lists.
+When displayed within a scrollable that creates its list items lazily,
+like a scrollable list created with `ListView.builder()`, they can be quite
+efficient, particularly for material design "expand/collapse" lists.
+
+Description:
+This app displays hierarchical data with ExpansionTiles. Tapping a tile
+expands or collapses the view of its children. When a tile is collapsed
+its children are disposed so that the widget footprint of the list only
+reflects what's visible.
+
+Classes: ExpansionTile, ListView
+
+Sample: ExpansionTileSample
+
+See also:
+  - The "expand/collapse" part of the material design specification:
+    <https://material.io/guidelines/components/lists-controls.html#lists-controls-types-of-list-controls>
+*/
diff --git a/examples/catalog/lib/tabbed_app_bar.dart b/examples/catalog/lib/tabbed_app_bar.dart
index 9c860fa..ab0cb6d 100644
--- a/examples/catalog/lib/tabbed_app_bar.dart
+++ b/examples/catalog/lib/tabbed_app_bar.dart
@@ -4,16 +4,38 @@
 
 import 'package:flutter/material.dart';
 
-/// Sample Catalog: Tabbed AppBar
-///
-/// A basic app bar with a tab bar at the bottom. One of the app's choices can be
-/// selected by tapping the tabs or by swiping the tab bar view.
-///
-/// Sample classes: [AppBar], [DefaultTabController], [TabBar], [Scaffold], [TabBarView].
-///
-/// See also:
-///  * The "Components-Tabs" section of the material design specification:
-///    <https://material.io/guidelines/components/tabs.html>
+class TabbedAppBarSample extends StatelessWidget {
+  @override
+  Widget build(BuildContext context) {
+    return new MaterialApp(
+      home: new DefaultTabController(
+        length: choices.length,
+        child: new Scaffold(
+          appBar: new AppBar(
+            title: const Text('Tabbed AppBar'),
+            bottom: new TabBar(
+              isScrollable: true,
+              tabs: choices.map((Choice choice) {
+                return new Tab(
+                  text: choice.title,
+                  icon: new Icon(choice.icon),
+                );
+              }).toList(),
+            ),
+          ),
+          body: new TabBarView(
+            children: choices.map((Choice choice) {
+              return new Padding(
+                padding: const EdgeInsets.all(16.0),
+                child: new ChoiceCard(choice: choice),
+              );
+            }).toList(),
+          ),
+        ),
+      ),
+    );
+  }
+}
 
 class Choice {
   const Choice({ this.title, this.icon });
@@ -54,37 +76,27 @@
   }
 }
 
-class TabbedAppBarSample extends StatelessWidget {
-  @override
-  Widget build(BuildContext context) {
-    return new DefaultTabController(
-      length: choices.length,
-      child: new Scaffold(
-        appBar: new AppBar(
-          title: const Text('Tabbed AppBar'),
-          bottom: new TabBar(
-            isScrollable: true,
-            tabs: choices.map((Choice choice) {
-              return new Tab(
-                text: choice.title,
-                icon: new Icon(choice.icon),
-              );
-            }).toList(),
-          ),
-        ),
-        body: new TabBarView(
-          children: choices.map((Choice choice) {
-            return new Padding(
-              padding: const EdgeInsets.all(16.0),
-              child: new ChoiceCard(choice: choice),
-            );
-          }).toList(),
-        ),
-      ),
-    );
-  }
+void main() {
+  runApp(new TabbedAppBarSample());
 }
 
-void main() {
-  runApp(new MaterialApp(home: new TabbedAppBarSample()));
-}
+/*
+Sample Catalog
+
+Title: Tabbed AppBar
+
+Summary: An AppBar can include a TabBar as its bottom widget.
+
+Description:
+A TabBar can be used to navigate among the pages displayed in a TabBarView.
+Although a TabBar is an ordinary widget that can appear, it's most often
+included in the application's AppBar.
+
+Classes: AppBar, DefaultTabController, TabBar, Scaffold, TabBarView
+
+Sample: TabbedAppBarSample
+
+See also:
+  - The "Components-Tabs" section of the material design specification:
+    <https://material.io/guidelines/components/tabs.html>
+*/
diff --git a/examples/catalog/test/smoke_test.dart b/examples/catalog/test/animated_list_test.dart
similarity index 100%
rename from examples/catalog/test/smoke_test.dart
rename to examples/catalog/test/animated_list_test.dart
diff --git a/examples/catalog/test_driver/README.md b/examples/catalog/test_driver/README.md
new file mode 100644
index 0000000..fe96af3
--- /dev/null
+++ b/examples/catalog/test_driver/README.md
@@ -0,0 +1 @@
+The screenshot_test.dart file was generated by ../bin/sample_page.dart. It should not be checked in.