Hook up Timeline buttons setup initial recording UI. (#1371)

* Hook up Timeline buttons and add full timeline record instructions.

* Review comments.

* More review comments.

* Fixes.

* add todo

* Fix broken test.

* Fix other broken test.
diff --git a/packages/devtools_app/lib/src/auto_dispose.dart b/packages/devtools_app/lib/src/auto_dispose.dart
index 44ccdc4..fca7aa5 100644
--- a/packages/devtools_app/lib/src/auto_dispose.dart
+++ b/packages/devtools_app/lib/src/auto_dispose.dart
@@ -29,7 +29,7 @@
 
   /// Add a listener to a Listenable object that is automatically removed when
   /// cancel is called.
-  void addAutoDisposeListener(Listenable listenable, VoidCallback listener) {
+  void addAutoDisposeListener(Listenable listenable, [VoidCallback listener]) {
     if (listenable == null || listener == null) return;
     _listenables.add(listenable);
     _listeners.add(listener);
@@ -79,7 +79,7 @@
   }
 
   @override
-  void addAutoDisposeListener(Listenable listenable, listener) {
+  void addAutoDisposeListener(Listenable listenable, [VoidCallback listener]) {
     _delegate.addAutoDisposeListener(listenable, listener);
   }
 
diff --git a/packages/devtools_app/lib/src/flutter/auto_dispose_mixin.dart b/packages/devtools_app/lib/src/flutter/auto_dispose_mixin.dart
index ad1e8d3..c1f1da0 100644
--- a/packages/devtools_app/lib/src/flutter/auto_dispose_mixin.dart
+++ b/packages/devtools_app/lib/src/flutter/auto_dispose_mixin.dart
@@ -27,9 +27,11 @@
     super.dispose();
   }
 
+  void _refresh() => setState(() {});
+
   @override
-  void addAutoDisposeListener(Listenable listenable, listener) {
-    _delegate.addAutoDisposeListener(listenable, listener);
+  void addAutoDisposeListener(Listenable listenable, [VoidCallback listener]) {
+    _delegate.addAutoDisposeListener(listenable, listener ?? _refresh);
   }
 
   @override
diff --git a/packages/devtools_app/lib/src/timeline/flutter/flutter_frames_chart.dart b/packages/devtools_app/lib/src/timeline/flutter/flutter_frames_chart.dart
index 60eed0d..773ec9a 100644
--- a/packages/devtools_app/lib/src/timeline/flutter/flutter_frames_chart.dart
+++ b/packages/devtools_app/lib/src/timeline/flutter/flutter_frames_chart.dart
@@ -5,7 +5,7 @@
 import 'dart:ui';
 
 import 'package:flutter/material.dart';
-
+import 'package:flutter/scheduler.dart';
 import 'package:mp_chart/mp/chart/bar_chart.dart';
 import 'package:mp_chart/mp/controller/bar_chart_controller.dart';
 import 'package:mp_chart/mp/core/adapter_android_mp.dart';
@@ -14,10 +14,10 @@
 import 'package:mp_chart/mp/core/data/bar_data.dart';
 import 'package:mp_chart/mp/core/data_set/bar_data_set.dart';
 import 'package:mp_chart/mp/core/description.dart';
-import 'package:mp_chart/mp/core/enums/limite_label_postion.dart';
-import 'package:mp_chart/mp/core/enums/x_axis_position.dart';
 import 'package:mp_chart/mp/core/entry/bar_entry.dart';
 import 'package:mp_chart/mp/core/entry/entry.dart';
+import 'package:mp_chart/mp/core/enums/limite_label_postion.dart';
+import 'package:mp_chart/mp/core/enums/x_axis_position.dart';
 import 'package:mp_chart/mp/core/highlight/highlight.dart';
 import 'package:mp_chart/mp/core/limit_line.dart';
 import 'package:mp_chart/mp/core/marker/line_chart_marker.dart';
@@ -27,6 +27,7 @@
 import 'package:mp_chart/mp/core/value_formatter/default_value_formatter.dart';
 import 'package:mp_chart/mp/core/value_formatter/value_formatter.dart';
 
+import '../../flutter/auto_dispose_mixin.dart';
 import '../../flutter/controllers.dart';
 import '../../ui/fake_flutter/_real_flutter.dart';
 import '../timeline_controller.dart';
@@ -40,6 +41,7 @@
 }
 
 class _FlutterFramesChartState extends State<FlutterFramesChart>
+    with AutoDisposeMixin
     implements OnChartValueSelectedListener {
   TimelineController _controller;
 
@@ -60,10 +62,24 @@
   @override
   void didChangeDependencies() {
     super.didChangeDependencies();
-    _controller = Controllers.of(context).timeline;
+    final newController = Controllers.of(context).timeline;
+    if (newController == _controller) return;
+    _controller = newController;
+
+    cancel();
+    autoDispose(_controller.onTimelineCleared.listen((_) {
+      setState(() {
+        frames.clear();
+        _frameDurations.clear();
+        _updateChart();
+      });
+    }));
 
     // Process each timeline frame.
-    _controller.frameBasedTimeline.onFrameAdded.listen((newFrame) {
+    addAutoDisposeListener(_controller.frameBasedTimeline.frameAddedNotifier,
+        () {
+      final newFrame = _controller.frameBasedTimeline.frameAddedNotifier.value;
+      if (newFrame == null) return;
       setState(() {
         // If frames not in sync with charting data (_frameDurations)?
         if (frames.isEmpty && _frameDurations.length == 1) {
@@ -356,7 +372,12 @@
 
     if (onSelected != null && _lastFrameIndex != frameIndex) {
       // Only fire when a different frame is selected.
-      onSelected(frameIndex);
+      // TODO(terry): reconfigure this code as selection should not be happening
+      // during paint. This task scheduling is a hack.
+      SchedulerBinding.instance.scheduleTask(
+        () => onSelected(frameIndex),
+        Priority.animation,
+      );
       _lastFrameIndex = frameIndex;
     }
   }
diff --git a/packages/devtools_app/lib/src/timeline/flutter/timeline_flame_chart.dart b/packages/devtools_app/lib/src/timeline/flutter/timeline_flame_chart.dart
index b460819..3ed3eeb 100644
--- a/packages/devtools_app/lib/src/timeline/flutter/timeline_flame_chart.dart
+++ b/packages/devtools_app/lib/src/timeline/flutter/timeline_flame_chart.dart
@@ -3,6 +3,7 @@
 // found in the LICENSE file.
 import 'dart:math' as math;
 
+import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 
 import '../../charts/flutter/flame_chart.dart';
@@ -21,34 +22,42 @@
     return LayoutBuilder(builder: (context, constraints) {
       return Padding(
         padding: const EdgeInsets.only(bottom: 8.0),
-        child: Container(
-          decoration: BoxDecoration(
-            border: Border.all(color: Theme.of(context).focusColor),
-          ),
-          child: controller.timelineMode == TimelineMode.frameBased
-              ? FrameBasedTimelineFlameChart(
-                  controller.frameBasedTimeline.data.selectedFrame,
-                  width: constraints.maxWidth,
-                  height: math.max(
-                    constraints.maxHeight,
-                    _frameBasedTimelineChartHeight(controller),
-                  ),
-                  selectionProvider: () =>
-                      controller.frameBasedTimeline.data.selectedEvent,
-                  onSelection: (e) => controller.selectTimelineEvent(e),
-                )
-              // TODO(kenz): implement full timeline flame chart.
-              : Container(
-                  color: Colors.black26,
-                  child: const Center(
-                    child: Text('TODO Full Timeline Flame Chart'),
-                  ),
-                ),
-        ),
+        child: controller.timelineModeNotifier.value == TimelineMode.frameBased
+            ? _buildFrameBasedTimeline(controller, constraints)
+            : _buildFullTimeline(controller, constraints),
       );
     });
   }
 
+  Widget _buildFrameBasedTimeline(
+    TimelineController controller,
+    BoxConstraints constraints,
+  ) {
+    return FrameBasedTimelineFlameChart(
+      controller.frameBasedTimeline.data.selectedFrame,
+      width: constraints.maxWidth,
+      height: math.max(
+        constraints.maxHeight,
+        _frameBasedTimelineChartHeight(controller),
+      ),
+      selectionNotifier: controller.selectedTimelineEventNotifier,
+      onSelection: (e) => controller.selectTimelineEvent(e),
+    );
+  }
+
+  Widget _buildFullTimeline(
+    TimelineController controller,
+    BoxConstraints constraints,
+  ) {
+    // TODO(kenz): implement full timeline flame chart.
+    return Container(
+      color: Colors.black26,
+      child: const Center(
+        child: Text('TODO Full Timeline Flame Chart'),
+      ),
+    );
+  }
+
   double _frameBasedTimelineChartHeight(TimelineController controller) {
     return (controller.frameBasedTimeline.data.displayDepth + 2) *
             rowHeightWithPadding +
@@ -62,7 +71,7 @@
     this.data, {
     @required this.height,
     @required double width,
-    @required this.selectionProvider,
+    @required this.selectionNotifier,
     @required this.onSelection,
   })  : duration = data.time.duration,
         startInset = sideInset,
@@ -78,7 +87,7 @@
 
   final double height;
 
-  final TimelineEvent Function() selectionProvider;
+  final ValueNotifier<TimelineEvent> selectionNotifier;
 
   final void Function(TimelineEvent event) onSelection;
 
@@ -100,21 +109,9 @@
 
   List<FlameChartRow> rows;
 
-  TimelineController _controller;
-
   int get gpuSectionStartRow => widget.data.uiEventFlow.depth;
 
   @override
-  void didChangeDependencies() {
-    super.didChangeDependencies();
-    _controller = Controllers.of(context).timeline;
-    cancel();
-    autoDispose(_controller.onSelectedTimelineEvent.listen((_) {
-      setState(() {});
-    }));
-  }
-
-  @override
   void didUpdateWidget(FrameBasedTimelineFlameChart oldWidget) {
     if (oldWidget.data != widget.data) {
       _scrollControllerX.jumpTo(startingScrollPosition);
@@ -177,23 +174,34 @@
     final height = math.max(constraints.maxHeight, widget.height);
 
     // TODO(kenz): rewrite this using slivers instead of a stack.
-    return Stack(
-      children: [
-        Container(
-          width: width,
-          height: height,
-        ),
-        ..._nodesInViewport(constraints), // pick what to show
-      ],
+    return ValueListenableBuilder(
+      valueListenable: widget.selectionNotifier,
+      builder: (context, selectedEvent, _) {
+        return Stack(
+          children: [
+            Container(
+              width: width,
+              height: height,
+            ),
+            ..._nodesInViewport(
+              constraints,
+              selectedEvent,
+            ), // pick what to show
+          ],
+        );
+      },
     );
   }
 
-  List<FlameChartNode> _nodesInViewport(BoxConstraints constraints) {
+  List<FlameChartNode> _nodesInViewport(
+    BoxConstraints constraints,
+    TimelineEvent selectedEvent,
+  ) {
     // TODO(kenz): is creating all the FlameChartNode objects expensive even if
     // we won't add them to the view? We create all the FlameChartNode objects
     // and place them in FlameChart rows, but we only add [nodesInViewport] to
     // the widget tree.
-    _buildFlameChartElements();
+    _buildFlameChartElements(selectedEvent);
 
     // TODO(kenz): Use binary search method we use in html full timeline here.
     final nodesInViewport = <FlameChartNode>[];
@@ -213,7 +221,7 @@
 
   // TODO(kenz): when optimizing this code, consider passing in the viewport
   // to only construct FlameChartNode elements that are in view.
-  void _buildFlameChartElements() {
+  void _buildFlameChartElements(TimelineEvent selectedEvent) {
     _resetColorOffsets();
 
     rows = List.generate(
@@ -278,7 +286,7 @@
             ? ThemedColor.fromSingleColor(Colors.black)
             : ThemedColor.fromSingleColor(contrastForegroundWhite),
         data: event,
-        selected: event == widget.selectionProvider(),
+        selected: event == selectedEvent,
         onSelected: (dynamic event) => widget.onSelection(event),
       );
 
diff --git a/packages/devtools_app/lib/src/timeline/flutter/timeline_screen.dart b/packages/devtools_app/lib/src/timeline/flutter/timeline_screen.dart
index d501870..59cd341 100644
--- a/packages/devtools_app/lib/src/timeline/flutter/timeline_screen.dart
+++ b/packages/devtools_app/lib/src/timeline/flutter/timeline_screen.dart
@@ -18,9 +18,31 @@
 import 'flutter_frames_chart.dart';
 import 'timeline_flame_chart.dart';
 
+// TODO(kenz): handle small screen widths better by using Wrap instead of Row
+// where applicable.
+
 class TimelineScreen extends Screen {
   const TimelineScreen() : super('Timeline');
 
+  @visibleForTesting
+  static const clearButtonKey = Key('Clear Button');
+  @visibleForTesting
+  static const flameChartSectionKey = Key('Flame Chart Section');
+  @visibleForTesting
+  static const pauseButtonKey = Key('Pause Button');
+  @visibleForTesting
+  static const resumeButtonKey = Key('Resume Button');
+  @visibleForTesting
+  static const emptyTimelineRecordingKey = Key('Empty Timeline Recording');
+  @visibleForTesting
+  static const recordButtonKey = Key('Record Button');
+  @visibleForTesting
+  static const recordingInstructionsKey = Key('Recording Instructions');
+  @visibleForTesting
+  static const recordingStatusKey = Key('Recording Status');
+  @visibleForTesting
+  static const stopRecordingButtonKey = Key('Stop Recording Button');
+
   @override
   Widget build(BuildContext context) => TimelineScreenBody();
 
@@ -42,19 +64,19 @@
     with AutoDisposeMixin {
   TimelineController controller;
 
+  TimelineMode get timelineMode => controller.timelineModeNotifier.value;
+
   @override
   void didChangeDependencies() {
     super.didChangeDependencies();
-    controller = Controllers.of(context).timeline;
+    final newController = Controllers.of(context).timeline;
+    if (newController == controller) return;
+    controller = newController;
+
     controller.timelineService.updateListeningState(true);
 
     cancel();
-    autoDispose(controller.frameBasedTimeline.onSelectedFrame.listen((_) {
-      setState(() {});
-    }));
-    autoDispose(controller.onSelectedTimelineEvent.listen((_) {
-      setState(() {});
-    }));
+    addAutoDisposeListener(controller.timelineModeNotifier);
   }
 
   @override
@@ -72,74 +94,38 @@
         Row(
           mainAxisAlignment: MainAxisAlignment.spaceBetween,
           children: [
-            Row(
-              children: _buildTimelineStateButtons(),
-            ),
-            Row(
-              mainAxisAlignment: MainAxisAlignment.end,
-              children: _buildSecondaryButtons(),
-            ),
+            _buildPrimaryStateControls(),
+            _buildSecondaryControls(),
           ],
         ),
-        if (controller.timelineMode == TimelineMode.frameBased)
-          const FlutterFramesChart(),
-        if (controller.timelineMode == TimelineMode.full ||
-            controller.frameBasedTimeline.data?.selectedFrame != null)
-          Expanded(
-            child: Split(
-              axis: Axis.vertical,
-              firstChild: TimelineFlameChart(),
-              secondChild: EventDetails(
-                controller.timeline.data?.selectedEvent,
-              ),
-              initialFirstFraction: 0.6,
-            ),
-          ),
+        if (timelineMode == TimelineMode.frameBased) const FlutterFramesChart(),
+        ValueListenableBuilder(
+          valueListenable: controller.frameBasedTimeline.selectedFrameNotifier,
+          builder: (context, selectedFrame, _) {
+            return (timelineMode == TimelineMode.full || selectedFrame != null)
+                ? Expanded(
+                    child: Split(
+                      axis: Axis.vertical,
+                      firstChild: _buildFlameChartSection(),
+                      secondChild: _buildEventDetailsSection(),
+                      initialFirstFraction: 0.6,
+                    ),
+                  )
+                : const SizedBox();
+          },
+        ),
       ],
     );
   }
 
-  List<Widget> _buildTimelineStateButtons() {
-    return [
-      if (controller.timelineMode == TimelineMode.frameBased) ...[
-        OutlineButton(
-          onPressed: _pauseLiveTimeline,
-          child: const MaterialIconLabel(
-            Icons.pause,
-            'Pause',
-            minIncludeTextWidth: 900,
-          ),
-        ),
-        OutlineButton(
-          onPressed: _resumeLiveTimeline,
-          child: const MaterialIconLabel(
-            Icons.play_arrow,
-            'Resume',
-            minIncludeTextWidth: 900,
-          ),
-        ),
-      ],
-      if (controller.timelineMode == TimelineMode.full) ...[
-        OutlineButton(
-          onPressed: _startRecording,
-          child: const MaterialIconLabel(
-            Icons.fiber_manual_record,
-            'Record',
-            minIncludeTextWidth: 900,
-          ),
-        ),
-        OutlineButton(
-          onPressed: _stopRecording,
-          child: const MaterialIconLabel(
-            Icons.stop,
-            'Stop',
-            minIncludeTextWidth: 900,
-          ),
-        ),
-      ],
+  Widget _buildPrimaryStateControls() {
+    final sharedWidgets = [
       const SizedBox(width: 8.0),
       OutlineButton(
-        onPressed: _clearTimeline,
+        key: TimelineScreen.clearButtonKey,
+        onPressed: () async {
+          await _clearTimeline();
+        },
         child: const MaterialIconLabel(
           Icons.block,
           'Clear',
@@ -151,7 +137,7 @@
         child: Row(
           children: [
             Switch(
-              value: controller.timelineMode == TimelineMode.frameBased,
+              value: timelineMode == TimelineMode.frameBased,
               onChanged: _onTimelineModeChanged,
             ),
             const Text('Show frames'),
@@ -159,59 +145,211 @@
         ),
       ),
     ];
+    return timelineMode == TimelineMode.frameBased
+        ? _buildFrameBasedTimelineButtons(sharedWidgets)
+        : _buildFullTimelineButtons(sharedWidgets);
   }
 
-  List<Widget> _buildSecondaryButtons() {
-    return [
-      Padding(
-        padding: const EdgeInsets.all(8.0),
-        child: ProfileGranularityDropdown(),
-      ),
-      ServiceExtensionButtonGroup(
-        minIncludeTextWidth: 1100,
-        extensions: [performanceOverlay],
-      ),
-      const SizedBox(width: 8.0),
-      OutlineButton(
-        onPressed: _exportTimeline,
-        child: MaterialIconLabel(
-          Icons.file_download,
-          'Export',
-          minIncludeTextWidth: 1100,
+  Widget _buildFrameBasedTimelineButtons(List<Widget> sharedWidgets) {
+    return ValueListenableBuilder(
+      valueListenable: controller.frameBasedTimeline.pausedNotifier,
+      builder: (context, paused, _) {
+        return Row(
+          children: [
+            OutlineButton(
+              key: TimelineScreen.pauseButtonKey,
+              onPressed: paused ? null : _pauseLiveTimeline,
+              child: const MaterialIconLabel(
+                Icons.pause,
+                'Pause',
+                minIncludeTextWidth: 900,
+              ),
+            ),
+            OutlineButton(
+              key: TimelineScreen.resumeButtonKey,
+              onPressed: !paused ? null : _resumeLiveTimeline,
+              child: const MaterialIconLabel(
+                Icons.play_arrow,
+                'Resume',
+                minIncludeTextWidth: 900,
+              ),
+            ),
+            ...sharedWidgets,
+          ],
+        );
+      },
+    );
+  }
+
+  Widget _buildFullTimelineButtons(List<Widget> sharedWidgets) {
+    return ValueListenableBuilder(
+      valueListenable: controller.fullTimeline.recordingNotifier,
+      builder: (context, recording, _) {
+        return Row(
+          children: [
+            OutlineButton(
+              key: TimelineScreen.recordButtonKey,
+              onPressed: recording ? null : _startRecording,
+              child: const MaterialIconLabel(
+                Icons.fiber_manual_record,
+                'Record',
+                minIncludeTextWidth: 900,
+              ),
+            ),
+            OutlineButton(
+              key: TimelineScreen.stopRecordingButtonKey,
+              onPressed: !recording ? null : _stopRecording,
+              child: const MaterialIconLabel(
+                Icons.stop,
+                'Stop',
+                minIncludeTextWidth: 900,
+              ),
+            ),
+            ...sharedWidgets,
+          ],
+        );
+      },
+    );
+  }
+
+  Widget _buildSecondaryControls() {
+    return Row(
+      mainAxisAlignment: MainAxisAlignment.end,
+      children: [
+        Padding(
+          padding: const EdgeInsets.all(8.0),
+          child: ProfileGranularityDropdown(),
         ),
+        ServiceExtensionButtonGroup(
+          minIncludeTextWidth: 1100,
+          extensions: [performanceOverlay],
+        ),
+        const SizedBox(width: 8.0),
+        OutlineButton(
+          onPressed: _exportTimeline,
+          child: MaterialIconLabel(
+            Icons.file_download,
+            'Export',
+            minIncludeTextWidth: 1100,
+          ),
+        ),
+      ],
+    );
+  }
+
+  Widget _buildFlameChartSection() {
+    Widget content;
+    final fullTimelineEmpty = controller.fullTimeline.data?.isEmpty ?? true;
+    if (timelineMode == TimelineMode.full && fullTimelineEmpty) {
+      content = ValueListenableBuilder(
+        valueListenable: controller.fullTimeline.emptyRecordingNotifier,
+        builder: (context, emptyRecording, _) {
+          return emptyRecording
+              ? const Center(
+                  key: TimelineScreen.emptyTimelineRecordingKey,
+                  child: Text('No timeline events recorded'),
+                )
+              : _buildRecordingInfo();
+        },
+      );
+    } else {
+      content = TimelineFlameChart();
+    }
+
+    return Container(
+      key: TimelineScreen.flameChartSectionKey,
+      decoration: BoxDecoration(
+        border: Border.all(color: Theme.of(context).focusColor),
       ),
-    ];
+      child: content,
+    );
+  }
+
+  Widget _buildRecordingInfo() {
+    final recordingInstructions = Column(
+      key: TimelineScreen.recordingInstructionsKey,
+      mainAxisAlignment: MainAxisAlignment.center,
+      children: [
+        Row(
+          mainAxisAlignment: MainAxisAlignment.center,
+          children: const [
+            Text('Click the record button '),
+            Icon(Icons.fiber_manual_record),
+            Text(' to start recording timeline trace.')
+          ],
+        ),
+        Row(
+          mainAxisAlignment: MainAxisAlignment.center,
+          children: const [
+            Text('Click the stop button '),
+            Icon(Icons.stop),
+            Text(' to end the recording.')
+          ],
+        ),
+      ],
+    );
+    final recordingStatus = Column(
+      key: TimelineScreen.recordingStatusKey,
+      mainAxisAlignment: MainAxisAlignment.center,
+      children: const [
+        Text('Recording timeline trace'),
+        SizedBox(height: 16.0),
+        CircularProgressIndicator(),
+      ],
+    );
+    return ValueListenableBuilder(
+      valueListenable: controller.fullTimeline.recordingNotifier,
+      builder: (context, recording, _) {
+        return Center(
+          child: recording ? recordingStatus : recordingInstructions,
+        );
+      },
+    );
+  }
+
+  Widget _buildEventDetailsSection() {
+    return ValueListenableBuilder(
+      valueListenable: controller.selectedTimelineEventNotifier,
+      builder: (context, selectedEvent, _) {
+        return EventDetails(selectedEvent);
+      },
+    );
   }
 
   void _pauseLiveTimeline() {
-    // TODO(kenz): implement.
+    setState(() {
+      controller.frameBasedTimeline.pause(manual: true);
+      controller.timelineService.updateListeningState(true);
+    });
   }
 
   void _resumeLiveTimeline() {
-    // TODO(kenz): implement.
+    setState(() {
+      controller.frameBasedTimeline.resume();
+      controller.timelineService.updateListeningState(true);
+    });
   }
 
-  void _startRecording() {
-    // TODO(kenz): implement.
+  void _startRecording() async {
+    await _clearTimeline();
+    controller.fullTimeline.startRecording();
   }
 
   void _stopRecording() {
-    // TODO(kenz): implement.
+    controller.fullTimeline.stopRecording();
   }
 
-  void _clearTimeline() {
-    // TODO(kenz): implement.
+  Future<void> _clearTimeline() async {
+    await controller.clearData();
   }
 
   void _exportTimeline() {
     // TODO(kenz): implement.
   }
 
-  // TODO(kenz): consider making timeline mode a ValueNotifier on the controller
-  void _onTimelineModeChanged(bool frameBased) {
-    setState(() {
-      controller.timelineMode =
-          frameBased ? TimelineMode.frameBased : TimelineMode.full;
-    });
+  void _onTimelineModeChanged(bool frameBased) async {
+    await _clearTimeline();
+    controller.selectTimelineMode(
+        frameBased ? TimelineMode.frameBased : TimelineMode.full);
   }
 }
diff --git a/packages/devtools_app/lib/src/timeline/html_event_details.dart b/packages/devtools_app/lib/src/timeline/html_event_details.dart
index 83a68b0..0f19584 100644
--- a/packages/devtools_app/lib/src/timeline/html_event_details.dart
+++ b/packages/devtools_app/lib/src/timeline/html_event_details.dart
@@ -117,11 +117,11 @@
   }
 
   void _initListeners() {
-    _timelineController.frameBasedTimeline.onSelectedFrame
-        .listen((_) => reset());
+    _timelineController.frameBasedTimeline.selectedFrameNotifier
+        .addListener(() => reset());
 
-    _timelineController.onSelectedTimelineEvent
-        .listen((_) async => await _update());
+    _timelineController.selectedTimelineEventNotifier
+        .addListener(() async => await _update());
 
     _timelineController.onLoadOfflineData.listen((_) async {
       // If there is no selected event, there is no reason to show the event
@@ -232,7 +232,8 @@
 
     final cpuProfileData = _timelineController.timeline.data?.cpuProfileData;
     if (cpuProfileData != null && cpuProfileData.stackFrames.isEmpty) {
-      final offset = _timelineController.timelineMode == TimelineMode.frameBased
+      final offset = _timelineController.timelineModeNotifier.value ==
+              TimelineMode.frameBased
           ? _timelineController.frameBasedTimeline.data.selectedFrame.time.start
           : _timelineController
               .fullTimeline.data.timelineEvents.first.time.start;
diff --git a/packages/devtools_app/lib/src/timeline/html_frames_bar_chart.dart b/packages/devtools_app/lib/src/timeline/html_frames_bar_chart.dart
index 8b9e33c..e3d047a 100644
--- a/packages/devtools_app/lib/src/timeline/html_frames_bar_chart.dart
+++ b/packages/devtools_app/lib/src/timeline/html_frames_bar_chart.dart
@@ -37,9 +37,8 @@
       }
     });
 
-    timelineController.frameBasedTimeline.onFrameAdded
-        .listen((TimelineFrame frame) {
-      frameUIgraph.process(frame);
+    timelineController.frameBasedTimeline.frameAddedNotifier.addListener(() {
+      frameUIgraph.processNextFrame();
     });
   }
 
@@ -96,7 +95,7 @@
         dataIndexes,
         uiDurations,
         gpuDurations,
-        timelineController.frameBasedTimeline.paused,
+        timelineController.frameBasedTimeline.pausedNotifier.value,
       );
 
       dataIndexes.removeRange(0, dataLength);
@@ -191,7 +190,9 @@
   }
 
   // Add current frame data to chunks of data for later plotting.
-  void process(TimelineFrame frame) async {
+  void processNextFrame() async {
+    final frame =
+        timelineController.frameBasedTimeline.frameAddedNotifier.value;
     if (frame.uiDurationMs > 0 && frame.gpuDurationMs > 0) {
       dataIndexes.add(_frameIndex);
       uiDurations.add(frame.uiDurationMs);
diff --git a/packages/devtools_app/lib/src/timeline/html_timeline_screen.dart b/packages/devtools_app/lib/src/timeline/html_timeline_screen.dart
index d68b27e..0d7e3c4 100644
--- a/packages/devtools_app/lib/src/timeline/html_timeline_screen.dart
+++ b/packages/devtools_app/lib/src/timeline/html_timeline_screen.dart
@@ -54,7 +54,7 @@
           enabled: enabled,
           disabledTooltip: disabledTooltip,
         ) {
-    timelineController.timelineMode = startTimelineMode;
+    timelineController.selectTimelineMode(startTimelineMode);
   }
 
   final TimelineMode startTimelineMode;
@@ -135,7 +135,7 @@
     _stopRecordingButton = PButton.icon('Stop', stop)
       ..small()
       ..clazz('margin-left')
-      ..disabled = !timelineController.fullTimeline.recording
+      ..disabled = !timelineController.fullTimeline.recordingNotifier.value
       ..click(_stopFullRecording);
 
     _recordingInstructions = createRecordingInstructions(
@@ -178,7 +178,8 @@
       ..setAttribute('type', 'checkbox');
     final html.InputElement checkbox = _timelineModeCheckbox.element;
     checkbox
-      ..checked = timelineController.timelineMode == TimelineMode.frameBased
+      ..checked = timelineController.timelineModeNotifier.value ==
+          TimelineMode.frameBased
       ..onChange.listen((_) => _setTimelineMode(
           timelineMode:
               checkbox.checked ? TimelineMode.frameBased : TimelineMode.full));
@@ -242,11 +243,11 @@
   @override
   void onContentAttached() {
     _updateVisibilityForTimelineMode();
-    if (timelineController.timelineMode == TimelineMode.full) {
+    if (timelineController.timelineModeNotifier.value == TimelineMode.full) {
       _configureSplitter();
     }
 
-    timelineController.frameBasedTimeline.onSelectedFrame.listen((_) {
+    timelineController.frameBasedTimeline.selectedFrameNotifier.addListener(() {
       _selectFrame();
     });
 
@@ -272,7 +273,7 @@
 
         _configureSplitter();
       })
-      ..onNoEventsRecorded.listen((_) {
+      ..emptyRecordingNotifier.addListener(() {
         _recordingStatusMessage.text = 'No timeline events recorded';
         _recordingStatus.hidden(false);
         _recordingSpinner.hidden(true);
@@ -315,13 +316,14 @@
     // https://github.com/dart-lang/sdk/issues/36798 is fixed.
     final observer = html.ResizeObserver((List<dynamic> entries, _) {
       if (timelineFlameChartCanvas == null ||
-          (timelineController.timelineMode == TimelineMode.frameBased &&
+          (timelineController.timelineModeNotifier.value ==
+                  TimelineMode.frameBased &&
               timelineController.frameBasedTimeline.data.selectedFrame ==
                   null)) {
         return;
       }
 
-      final dataHeight = timelineController.timelineMode ==
+      final dataHeight = timelineController.timelineModeNotifier.value ==
               TimelineMode.frameBased
           ? // Add 1 to account for a row of padding at the bottom of the chart.
           _frameBasedTimelineChartHeight()
@@ -462,7 +464,8 @@
   }
 
   Future<void> _pauseFrameRecording() async {
-    assert(timelineController.timelineMode == TimelineMode.frameBased);
+    assert(timelineController.timelineModeNotifier.value ==
+        TimelineMode.frameBased);
     timelineController.frameBasedTimeline.pause(manual: true);
     ga.select(ga.timeline, ga.pause);
     _updateButtonStates();
@@ -471,7 +474,8 @@
   }
 
   Future<void> _resumeFrameRecording() async {
-    assert(timelineController.timelineMode == TimelineMode.frameBased);
+    assert(timelineController.timelineModeNotifier.value ==
+        TimelineMode.frameBased);
     timelineController.frameBasedTimeline.resume();
     ga.select(ga.timeline, ga.resume);
     _updateButtonStates();
@@ -480,7 +484,7 @@
   }
 
   Future<void> _startFullRecording() async {
-    assert(timelineController.timelineMode == TimelineMode.full);
+    assert(timelineController.timelineModeNotifier.value == TimelineMode.full);
     await clearTimeline();
     timelineController.fullTimeline.startRecording();
     _recordingInstructions.hidden(true);
@@ -491,7 +495,7 @@
   }
 
   void _stopFullRecording() {
-    assert(timelineController.timelineMode == TimelineMode.full);
+    assert(timelineController.timelineModeNotifier.value == TimelineMode.full);
     _recordingStatusMessage.text = 'Processing timeline trace';
     timelineController.fullTimeline.stopRecording();
     _recordingStatus.hidden(true);
@@ -509,7 +513,7 @@
       timelineController.timeline.data?.clear();
     }
 
-    timelineController.timelineMode = timelineMode;
+    timelineController.selectTimelineMode(timelineMode);
 
     // Update visibility and then do resets - the order matters here.
     _updateVisibilityForTimelineMode();
@@ -534,31 +538,35 @@
     final isDartCliApp = await serviceManager.connectedApp.isDartCliApp;
     pauseButton
       ..disabled = timelineController.frameBasedTimeline.manuallyPaused
-      ..hidden(
-          offlineMode || timelineController.timelineMode == TimelineMode.full);
+      ..hidden(offlineMode ||
+          timelineController.timelineModeNotifier.value == TimelineMode.full);
     resumeButton
       ..disabled = !timelineController.frameBasedTimeline.manuallyPaused
-      ..hidden(
-          offlineMode || timelineController.timelineMode == TimelineMode.full);
+      ..hidden(offlineMode ||
+          timelineController.timelineModeNotifier.value == TimelineMode.full);
     _startRecordingButton
-      ..disabled = timelineController.fullTimeline.recording
+      ..disabled = timelineController.fullTimeline.recordingNotifier.value
       ..hidden(offlineMode ||
-          timelineController.timelineMode == TimelineMode.frameBased);
+          timelineController.timelineModeNotifier.value ==
+              TimelineMode.frameBased);
     _stopRecordingButton
-      ..disabled = !timelineController.fullTimeline.recording
+      ..disabled = !timelineController.fullTimeline.recordingNotifier.value
       ..hidden(offlineMode ||
-          timelineController.timelineMode == TimelineMode.frameBased);
-    _timelineModeCheckbox.disabled = timelineController.fullTimeline.recording;
+          timelineController.timelineModeNotifier.value ==
+              TimelineMode.frameBased);
+    _timelineModeCheckbox.disabled =
+        timelineController.fullTimeline.recordingNotifier.value;
     (_timelineModeCheckbox.element as html.InputElement).checked =
-        timelineController.timelineMode == TimelineMode.frameBased;
+        timelineController.timelineModeNotifier.value ==
+            TimelineMode.frameBased;
 
     _timelineModeSettingContainer.hidden(offlineMode || isDartCliApp);
 
     clearButton
-      ..disabled = timelineController.fullTimeline.recording
+      ..disabled = timelineController.fullTimeline.recordingNotifier.value
       ..hidden(offlineMode);
     exportButton
-      ..disabled = timelineController.fullTimeline.recording
+      ..disabled = timelineController.fullTimeline.recordingNotifier.value
       ..hidden(offlineMode);
     performanceOverlayButton.button.hidden(offlineMode || isDartCliApp);
     _profileGranularitySelector.selector.hidden(offlineMode);
@@ -567,28 +575,31 @@
 
   void _updateVisibilityForTimelineMode() {
     _updateButtonStates();
-    framesBarChart.hidden(timelineController.timelineMode == TimelineMode.full);
-    flameChartContainer
-        .hidden(timelineController.timelineMode == TimelineMode.frameBased);
-    _recordingInstructions
-        .hidden(timelineController.timelineMode == TimelineMode.frameBased);
+    framesBarChart.hidden(
+        timelineController.timelineModeNotifier.value == TimelineMode.full);
+    flameChartContainer.hidden(timelineController.timelineModeNotifier.value ==
+        TimelineMode.frameBased);
+    _recordingInstructions.hidden(
+        timelineController.timelineModeNotifier.value ==
+            TimelineMode.frameBased);
     _recordingStatus.hidden(true);
-    eventDetails
-        .hidden(timelineController.timelineMode == TimelineMode.frameBased);
+    eventDetails.hidden(timelineController.timelineModeNotifier.value ==
+        TimelineMode.frameBased);
   }
 
   Future<void> clearTimeline() async {
     await timelineController.clearData();
     _setFlameChart(_emptyFlameChart);
-    flameChartContainer
-        .hidden(timelineController.timelineMode == TimelineMode.frameBased);
+    flameChartContainer.hidden(timelineController.timelineModeNotifier.value ==
+        TimelineMode.frameBased);
     timelineFlameChartCanvas?.element?.element?.remove();
     timelineFlameChartCanvas = null;
     eventDetails.reset(
-        hide: timelineController.timelineMode == TimelineMode.frameBased);
+        hide: timelineController.timelineModeNotifier.value ==
+            TimelineMode.frameBased);
     _recordingStatus.hidden(true);
 
-    switch (timelineController.timelineMode) {
+    switch (timelineController.timelineModeNotifier.value) {
       case TimelineMode.frameBased:
         debugHandledTraceEvents.clear();
         debugFrameTracking.clear();
diff --git a/packages/devtools_app/lib/src/timeline/timeline_controller.dart b/packages/devtools_app/lib/src/timeline/timeline_controller.dart
index 806c2af..30b6fc9 100644
--- a/packages/devtools_app/lib/src/timeline/timeline_controller.dart
+++ b/packages/devtools_app/lib/src/timeline/timeline_controller.dart
@@ -10,6 +10,7 @@
 import '../profiler/cpu_profile_service.dart';
 import '../profiler/cpu_profile_transformer.dart';
 import '../service_manager.dart';
+import '../ui/fake_flutter/fake_flutter.dart';
 import 'timeline_model.dart';
 import 'timeline_processor.dart';
 import 'timeline_service.dart';
@@ -30,17 +31,14 @@
   TimelineController() {
     timelineService = TimelineService(this);
     fullTimeline = FullTimeline(this);
+    frameBasedTimeline = FrameBasedTimeline(this);
     timelines = [frameBasedTimeline, fullTimeline];
   }
 
-  /// Stream controller that notifies a timeline event was selected.
-  ///
-  /// Subscribers to this stream will be responsible for updating the UI for the
-  /// new value of [timelineData.selectedEvent]. We send the
-  /// [FrameFlameChartItem] so that we can persist the colors through to the
-  /// event details view.
-  final _selectedTimelineEventController =
-      StreamController<TimelineEvent>.broadcast();
+  /// Notifies that a timeline event was selected.
+  ValueListenable get selectedTimelineEventNotifier =>
+      _selectedTimelineEventNotifier;
+  final _selectedTimelineEventNotifier = ValueNotifier<TimelineEvent>(null);
 
   /// Stream controller that notifies that offline data was loaded into the
   /// timeline.
@@ -53,19 +51,22 @@
   /// should be logged for the timeline.
   final _nonFatalErrorController = StreamController<String>.broadcast();
 
-  Stream<TimelineEvent> get onSelectedTimelineEvent =>
-      _selectedTimelineEventController.stream;
+  /// Stream controller that notifies the timeline has been cleared.
+  final _clearController = StreamController<bool>.broadcast();
+
+  Stream<bool> get onTimelineCleared => _clearController.stream;
 
   Stream<OfflineData> get onLoadOfflineData =>
       _loadOfflineDataController.stream;
 
   Stream<String> get onNonFatalError => _nonFatalErrorController.stream;
 
-  TimelineBase get timeline => timelineMode == TimelineMode.frameBased
-      ? frameBasedTimeline
-      : fullTimeline;
+  TimelineBase get timeline =>
+      timelineModeNotifier.value == TimelineMode.frameBased
+          ? frameBasedTimeline
+          : fullTimeline;
 
-  final frameBasedTimeline = FrameBasedTimeline();
+  FrameBasedTimeline frameBasedTimeline;
 
   FullTimeline fullTimeline;
 
@@ -79,13 +80,15 @@
 
   final _cpuProfilerService = CpuProfilerService();
 
-  TimelineMode timelineMode = TimelineMode.frameBased;
+  ValueListenable get timelineModeNotifier => _timelineModeNotifier;
+  final _timelineModeNotifier =
+      ValueNotifier<TimelineMode>(TimelineMode.frameBased);
 
   /// Trace events we received while listening to the Timeline event stream.
   ///
   /// This does not include events that we receive while paused (if
-  /// [timelineMode] == [TimelineMode.frameBased]) or stopped (if
-  /// [timelineMode] == [TimelineMode.full]).
+  /// [timelineModeNotifier] == [TimelineMode.frameBased]) or stopped (if
+  /// [timelineModeNotifier] == [TimelineMode.full]).
   ///
   /// These events will be used to switch timeline modes (frameBased vs full).
   /// The selected mode will process these events using the respective processor
@@ -96,10 +99,14 @@
   bool get hasStarted =>
       frameBasedTimeline.hasStarted && fullTimeline.hasStarted;
 
+  void selectTimelineMode(TimelineMode mode) {
+    _timelineModeNotifier.value = mode;
+  }
+
   void selectTimelineEvent(TimelineEvent event) {
     if (event == null || timeline.data.selectedEvent == event) return;
     timeline.data.selectedEvent = event;
-    _selectedTimelineEventController.add(event);
+    _selectedTimelineEventNotifier.value = event;
   }
 
   Future<void> getCpuProfileForSelectedEvent() async {
@@ -129,7 +136,7 @@
     final uiThreadId = _threadIdForEvent(uiEventName, traceEvents);
     final gpuThreadId = _threadIdForEvent(gpuEventName, traceEvents);
 
-    timelineMode = offlineData.timelineMode;
+    _timelineModeNotifier.value = offlineData.timelineMode;
     offlineTimelineData = offlineData.shallowClone();
     timeline
       ..data = offlineData.shallowClone()
@@ -151,7 +158,7 @@
       // TODO(kenz): the flame chart should listen to this stream and
       // programmatically select the flame chart node that corresponds to
       // the selected event.
-      _selectedTimelineEventController.add(offlineTimelineData.selectedEvent);
+      _selectedTimelineEventNotifier.value = offlineTimelineData.selectedEvent;
     }
 
     if (offlineTimelineData is OfflineFullTimelineData) {
@@ -184,7 +191,7 @@
         frameBasedTimeline.data.selectedFrame = frameToSelect;
         // TODO(kenz): frames bar chart should listen to this stream and
         // programmatially select the frame from the offline snapshot.
-        frameBasedTimeline._selectedFrameController.add(frameToSelect);
+        frameBasedTimeline._selectedFrameNotifier.value = frameToSelect;
 
         if (offlineTimelineData.selectedEvent != null) {
           eventToSelect = frameToSelect
@@ -211,7 +218,7 @@
       timeline.data
         ..selectedEvent = eventToSelect
         ..cpuProfileData = offlineTimelineData.cpuProfileData;
-      _selectedTimelineEventController.add(eventToSelect);
+      _selectedTimelineEventNotifier.value = eventToSelect;
     }
   }
 
@@ -227,6 +234,8 @@
     frameBasedTimeline.clear();
     fullTimeline.clear();
     allTraceEvents.clear();
+    _selectedTimelineEventNotifier.value = null;
+    _clearController.add(true);
   }
 
   void recordTrace(Map<String, dynamic> trace) {
@@ -248,21 +257,17 @@
 
 class FrameBasedTimeline
     extends TimelineBase<FrameBasedTimelineData, FrameBasedTimelineProcessor> {
-  /// Stream controller that notifies a frame was added to the timeline.
-  ///
-  /// Subscribers to this stream will be responsible for updating the UI for the
-  /// new value of [frameBasedTimelineData.frames].
-  final _frameAddedController = StreamController<TimelineFrame>.broadcast();
+  FrameBasedTimeline(this._timelineController);
 
-  /// Stream controller that notifies a frame was selected.
-  ///
-  /// Subscribers to this stream will be responsible for updating the UI for the
-  /// new value of [frameBasedTimelineData.selectedFrame].
-  final _selectedFrameController = StreamController<TimelineFrame>.broadcast();
+  final TimelineController _timelineController;
 
-  Stream<TimelineFrame> get onFrameAdded => _frameAddedController.stream;
+  /// Notifies that a frame has been added to the timeline.
+  ValueListenable get frameAddedNotifier => _frameAddedNotifier;
+  final _frameAddedNotifier = ValueNotifier<TimelineFrame>(null);
 
-  Stream<TimelineFrame> get onSelectedFrame => _selectedFrameController.stream;
+  /// Notifies that a timeline frame has been selected.
+  ValueListenable get selectedFrameNotifier => _selectedFrameNotifier;
+  final _selectedFrameNotifier = ValueNotifier<TimelineFrame>(null);
 
   Future<double> get displayRefreshRate async {
     final refreshRate =
@@ -274,28 +279,30 @@
   /// Whether the timeline has been manually paused via the Pause button.
   bool manuallyPaused = false;
 
-  bool get paused => _paused;
-
-  bool _paused = false;
+  /// Notifies that the timeline has been paused.
+  ValueListenable get pausedNotifier => _pausedNotifier;
+  final _pausedNotifier = ValueNotifier<bool>(false);
 
   void pause({bool manual = false}) {
     manuallyPaused = manual;
-    _paused = true;
+    _pausedNotifier.value = true;
   }
 
   void resume() {
     manuallyPaused = false;
-    _paused = false;
+    _pausedNotifier.value = false;
   }
 
   void selectFrame(TimelineFrame frame) {
     if (frame == null || data.selectedFrame == frame || !hasStarted) {
       return;
     }
+    _selectedFrameNotifier.value = frame;
     data.selectedFrame = frame;
+
+    _timelineController._selectedTimelineEventNotifier.value = null;
     data.selectedEvent = null;
     data.cpuProfileData = null;
-    _selectedFrameController.add(frame);
 
     if (debugTimeline && frame != null) {
       final buf = StringBuffer();
@@ -313,7 +320,7 @@
 
   void addFrame(TimelineFrame frame) {
     data.frames.add(frame);
-    _frameAddedController.add(frame);
+    _frameAddedNotifier.value = frame;
   }
 
   @override
@@ -338,6 +345,14 @@
     // processing for every frame in the snapshot.
     processor.maybeAddPendingEvents();
   }
+
+  @override
+  void clear() {
+    super.clear();
+    _frameAddedNotifier.value = null;
+    _selectedFrameNotifier.value = null;
+    _pausedNotifier.value = false;
+  }
 }
 
 class FullTimeline
@@ -348,24 +363,25 @@
 
   final _timelineProcessedController = StreamController<bool>.broadcast();
 
-  final _noEventsRecordedController = StreamController<bool>.broadcast();
+  /// Notifies when an empty timeline recording finishes
+  ValueListenable get emptyRecordingNotifier => _emptyRecordingNotifier;
+  final _emptyRecordingNotifier = ValueNotifier<bool>(false);
 
   Stream<bool> get onTimelineProcessed => _timelineProcessedController.stream;
 
-  Stream<bool> get onNoEventsRecorded => _noEventsRecordedController.stream;
-
-  /// Whether the timeline is being recorded.
-  bool recording = false;
+  /// Notifies that the timeline is currently being recorded.
+  ValueListenable get recordingNotifier => _recordingNotifier;
+  final _recordingNotifier = ValueNotifier<bool>(false);
 
   void startRecording() async {
-    recording = true;
+    _recordingNotifier.value = true;
   }
 
   void stopRecording() {
-    recording = false;
+    _recordingNotifier.value = false;
 
     if (_timelineController.allTraceEvents.isEmpty) {
-      _noEventsRecordedController.add(true);
+      _emptyRecordingNotifier.value = true;
       return;
     }
 
@@ -395,6 +411,13 @@
     processor.processTimeline(traceEvents);
     _timelineController.fullTimeline.data.initializeEventBuckets();
   }
+
+  @override
+  void clear() {
+    super.clear();
+    _recordingNotifier.value = false;
+    _emptyRecordingNotifier.value = false;
+  }
 }
 
 abstract class TimelineBase<T extends TimelineData,
diff --git a/packages/devtools_app/lib/src/timeline/timeline_service.dart b/packages/devtools_app/lib/src/timeline/timeline_service.dart
index 474b596..6a97a53 100644
--- a/packages/devtools_app/lib/src/timeline/timeline_service.dart
+++ b/packages/devtools_app/lib/src/timeline/timeline_service.dart
@@ -44,12 +44,13 @@
           list.cast<Map<String, dynamic>>();
 
       final bool shouldProcessEventForFrameBasedTimeline =
-          timelineController.timelineMode == TimelineMode.frameBased &&
+          timelineController.timelineModeNotifier.value ==
+                  TimelineMode.frameBased &&
               !timelineController.frameBasedTimeline.manuallyPaused &&
-              !timelineController.frameBasedTimeline.paused;
+              !timelineController.frameBasedTimeline.pausedNotifier.value;
       final bool shouldProcessEventForFullTimeline =
-          timelineController.timelineMode == TimelineMode.full &&
-              timelineController.fullTimeline.recording;
+          timelineController.timelineModeNotifier.value == TimelineMode.full &&
+              timelineController.fullTimeline.recordingNotifier.value;
 
       if (!offlineMode &&
           (shouldProcessEventForFrameBasedTimeline ||
@@ -63,7 +64,8 @@
 
           // For [TimelineMode.frameBased], process the events as we receive
           // them.
-          if (timelineController.timelineMode == TimelineMode.frameBased) {
+          if (timelineController.timelineModeNotifier.value ==
+              TimelineMode.frameBased) {
             timelineController.frameBasedTimeline.processor
                 ?.processTraceEvent(eventWrapper);
           }
@@ -143,11 +145,12 @@
   Future<void> updateListeningState(bool isCurrentScreen) async {
     final bool shouldBeRunning =
         (!timelineController.frameBasedTimeline.manuallyPaused ||
-                timelineController.fullTimeline.recording) &&
+                timelineController.fullTimeline.recordingNotifier.value) &&
             !offlineMode &&
             isCurrentScreen;
-    final bool isRunning = !timelineController.frameBasedTimeline.paused ||
-        timelineController.fullTimeline.recording;
+    final bool isRunning =
+        !timelineController.frameBasedTimeline.pausedNotifier.value ||
+            timelineController.fullTimeline.recordingNotifier.value;
     await _updateListeningState(
       shouldBeRunning: shouldBeRunning,
       isRunning: isRunning,
diff --git a/packages/devtools_app/test/flutter/auto_dispose_mixin_test.dart b/packages/devtools_app/test/flutter/auto_dispose_mixin_test.dart
index 558d8a7..6a7d4bf 100644
--- a/packages/devtools_app/test/flutter/auto_dispose_mixin_test.dart
+++ b/packages/devtools_app/test/flutter/auto_dispose_mixin_test.dart
@@ -5,11 +5,10 @@
 import 'dart:async';
 
 import 'package:devtools_app/src/auto_dispose.dart';
+import 'package:devtools_app/src/flutter/auto_dispose_mixin.dart';
 import 'package:flutter/widgets.dart';
 import 'package:flutter_test/flutter_test.dart';
 
-import 'package:devtools_app/src/flutter/auto_dispose_mixin.dart';
-
 class AutoDisposedWidget extends StatefulWidget {
   const AutoDisposedWidget(this.stream, {Key key}) : super(key: key);
 
diff --git a/packages/devtools_app/test/flutter/timeline_flame_chart_test.dart b/packages/devtools_app/test/flutter/timeline_flame_chart_test.dart
index a7055b0..da1124b 100644
--- a/packages/devtools_app/test/flutter/timeline_flame_chart_test.dart
+++ b/packages/devtools_app/test/flutter/timeline_flame_chart_test.dart
@@ -32,15 +32,16 @@
 
       final mockData = MockFrameBasedTimelineData();
       when(mockData.displayDepth).thenReturn(8);
-      when(mockData.selectedFrame).thenReturn(testFrame);
+      when(mockData.selectedFrame).thenReturn(testFrame0);
       final controllerWithData = TimelineController()
-        ..frameBasedTimeline.data = mockData;
+        ..frameBasedTimeline.data = mockData
+        ..frameBasedTimeline.selectFrame(testFrame1);
       await tester.pumpWidget(wrapWithControllers(
         TimelineScreenBody(),
         timelineController: controllerWithData,
       ));
       expect(find.byType(FrameBasedTimelineFlameChart), findsOneWidget);
-      expect(find.text('TODO Full Timeline Flame Chart'), findsNothing);
+      expect(find.byKey(TimelineScreen.recordingInstructionsKey), findsNothing);
     });
 
     testWidgets('builds full timeline', (WidgetTester tester) async {
@@ -50,10 +51,13 @@
       await tester.pumpWidget(wrapWithControllers(
         TimelineScreenBody(),
         timelineController: TimelineController()
-          ..timelineMode = TimelineMode.full,
+          ..selectTimelineMode(TimelineMode.full),
       ));
       expect(find.byType(FrameBasedTimelineFlameChart), findsNothing);
-      expect(find.text('TODO Full Timeline Flame Chart'), findsOneWidget);
+      expect(
+        find.byKey(TimelineScreen.recordingInstructionsKey),
+        findsOneWidget,
+      );
     });
   });
 }
diff --git a/packages/devtools_app/test/flutter/timeline_screen_test.dart b/packages/devtools_app/test/flutter/timeline_screen_test.dart
index 2eab876..a5341f9 100644
--- a/packages/devtools_app/test/flutter/timeline_screen_test.dart
+++ b/packages/devtools_app/test/flutter/timeline_screen_test.dart
@@ -23,8 +23,44 @@
 
 void main() {
   TimelineScreen screen;
+  TimelineScreenBodyState state;
+  TimelineController controller;
   FakeServiceManager fakeServiceManager;
 
+  Future<void> pumpTimelineScreen(
+    WidgetTester tester,
+    TimelineMode mode, {
+    TimelineController timelineController,
+  }) async {
+    // Set a wide enough screen width that we do not run into overflow.
+    await setWindowSize(const Size(1599.0, 1000.0));
+    await tester.pumpWidget(wrapWithControllers(
+      TimelineScreenBody(),
+      timelineController:
+          controller = timelineController ?? TimelineController()
+            ..selectTimelineMode(mode),
+    ));
+    expect(find.byType(TimelineScreenBody), findsOneWidget);
+
+    state = tester.state(find.byType(TimelineScreenBody));
+    expect(state.controller.timelineModeNotifier.value, equals(mode));
+  }
+
+  Future<void> pumpTimelineWithSelectedFrame(WidgetTester tester) async {
+    final mockData = MockFrameBasedTimelineData();
+    when(mockData.displayDepth).thenReturn(8);
+    when(mockData.selectedFrame).thenReturn(testFrame0);
+    final controllerWithData = TimelineController()
+      ..allTraceEvents.addAll(goldenUiTraceEvents)
+      ..frameBasedTimeline.data = mockData
+      ..frameBasedTimeline.selectFrame(testFrame1);
+    await pumpTimelineScreen(
+      tester,
+      TimelineMode.frameBased,
+      timelineController: controllerWithData,
+    );
+  }
+
   group('TimelineScreen', () {
     setUp(() async {
       await ensureInspectorDependencies();
@@ -41,42 +77,27 @@
     });
 
     testWidgets('builds proper content for state', (WidgetTester tester) async {
-      // Set a wide enough screen width that we do not run into overflow.
-      await setWindowSize(const Size(1599.0, 1000.0));
-      await tester.pumpWidget(wrapWithControllers(
-        TimelineScreenBody(),
-        timelineController: TimelineController(),
-      ));
-      expect(find.byType(TimelineScreenBody), findsOneWidget);
-      final TimelineScreenBodyState state =
-          tester.state(find.byType(TimelineScreenBody));
+      await pumpTimelineScreen(tester, TimelineMode.frameBased);
 
       final splitFinder = find.byType(Split);
 
       // Verify TimelineMode.frameBased content.
-      expect(state.controller.timelineMode, equals(TimelineMode.frameBased));
       expect(splitFinder, findsNothing);
       expect(find.text('Pause'), findsOneWidget);
       expect(find.text('Resume'), findsOneWidget);
       expect(find.text('Record'), findsNothing);
       expect(find.text('Stop'), findsNothing);
       expect(find.byType(FlutterFramesChart), findsOneWidget);
-      expect(find.byType(TimelineFlameChart), findsNothing);
+      expect(find.byKey(TimelineScreen.flameChartSectionKey), findsNothing);
       expect(find.byType(EventDetails), findsNothing);
 
       // Add a selected frame and ensure the flame chart and event details
       // section appear.
-      final mockData = MockFrameBasedTimelineData();
-      when(mockData.displayDepth).thenReturn(8);
-      when(mockData.selectedFrame).thenReturn(testFrame);
-      final controllerWithData = TimelineController()
-        ..frameBasedTimeline.data = mockData;
-      await tester.pumpWidget(wrapWithControllers(
-        TimelineScreenBody(),
-        timelineController: controllerWithData,
-      ));
+      await pumpTimelineWithSelectedFrame(tester);
       expect(find.byType(FlutterFramesChart), findsOneWidget);
+      expect(find.byKey(TimelineScreen.flameChartSectionKey), findsOneWidget);
       expect(find.byType(TimelineFlameChart), findsOneWidget);
+      expect(find.byKey(TimelineScreen.recordingInstructionsKey), findsNothing);
       expect(find.byType(EventDetails), findsOneWidget);
 
       // Switch timeline mode and pump.
@@ -84,19 +105,127 @@
       await tester.pump();
 
       // Verify TimelineMode.full content.
-      expect(state.controller.timelineMode, equals(TimelineMode.full));
+      expect(
+        state.controller.timelineModeNotifier.value,
+        equals(TimelineMode.full),
+      );
       expect(find.text('Pause'), findsNothing);
       expect(find.text('Resume'), findsNothing);
       expect(find.text('Record'), findsOneWidget);
       expect(find.text('Stop'), findsOneWidget);
       expect(find.byType(FlutterFramesChart), findsNothing);
-      expect(find.byType(TimelineFlameChart), findsOneWidget);
+      expect(find.byKey(TimelineScreen.flameChartSectionKey), findsOneWidget);
+      expect(find.byType(TimelineFlameChart), findsNothing);
+      expect(
+        find.byKey(TimelineScreen.recordingInstructionsKey),
+        findsOneWidget,
+      );
       expect(find.byType(EventDetails), findsOneWidget);
 
       // Verify the state of the splitter.
       expect(splitFinder, findsOneWidget);
       final Split splitter = tester.widget(splitFinder);
       expect(splitter.initialFirstFraction, equals(0.6));
+
+      await resetWindowSize();
+    });
+
+    testWidgets('pauses and resumes', (WidgetTester tester) async {
+      await pumpTimelineScreen(tester, TimelineMode.frameBased);
+
+      // Verify initial state.
+      expect(controller.frameBasedTimeline.pausedNotifier.value, isFalse);
+      expect(controller.frameBasedTimeline.manuallyPaused, isFalse);
+
+      // Pause.
+      await tester.tap(find.byKey(TimelineScreen.pauseButtonKey));
+      await tester.pump();
+      expect(controller.frameBasedTimeline.pausedNotifier.value, isTrue);
+      expect(controller.frameBasedTimeline.manuallyPaused, isTrue);
+
+      // Resume.
+      await tester.tap(find.byKey(TimelineScreen.resumeButtonKey));
+      await tester.pump();
+      expect(controller.frameBasedTimeline.pausedNotifier.value, isFalse);
+      expect(controller.frameBasedTimeline.manuallyPaused, isFalse);
+    });
+
+    testWidgets('starts and stops recording', (WidgetTester tester) async {
+      await pumpTimelineScreen(tester, TimelineMode.full);
+
+      // Verify initial state.
+      expect(
+        find.byKey(TimelineScreen.recordingInstructionsKey),
+        findsOneWidget,
+      );
+      expect(find.byKey(TimelineScreen.recordingStatusKey), findsNothing);
+      expect(controller.fullTimeline.recordingNotifier.value, isFalse);
+
+      // Start recording.
+      await tester.tap(find.byKey(TimelineScreen.recordButtonKey));
+      await tester.pump();
+      expect(find.byKey(TimelineScreen.recordingInstructionsKey), findsNothing);
+      expect(find.byKey(TimelineScreen.recordingStatusKey), findsOneWidget);
+      expect(controller.fullTimeline.recordingNotifier.value, isTrue);
+
+      // Stop recording.
+      await tester.tap(find.byKey(TimelineScreen.stopRecordingButtonKey));
+      await tester.pump();
+      expect(find.byKey(TimelineScreen.recordingInstructionsKey), findsNothing);
+      expect(find.byKey(TimelineScreen.recordingStatusKey), findsNothing);
+      expect(
+        find.byKey(TimelineScreen.emptyTimelineRecordingKey),
+        findsOneWidget,
+      );
+      expect(controller.fullTimeline.recordingNotifier.value, isFalse);
+
+      await resetWindowSize();
+    });
+
+    testWidgets('clears timeline on clear', (WidgetTester tester) async {
+      // Clear the frame-based timeline.
+      await pumpTimelineWithSelectedFrame(tester);
+
+      expect(controller.allTraceEvents, isNotEmpty);
+      expect(find.byType(FlutterFramesChart), findsOneWidget);
+      expect(find.byKey(TimelineScreen.flameChartSectionKey), findsOneWidget);
+      expect(find.byType(TimelineFlameChart), findsOneWidget);
+      expect(find.byKey(TimelineScreen.recordingInstructionsKey), findsNothing);
+      expect(find.byType(EventDetails), findsOneWidget);
+
+      await tester.tap(find.byKey(TimelineScreen.clearButtonKey));
+      await tester.pump();
+      expect(find.byType(FlutterFramesChart), findsOneWidget);
+      expect(find.byKey(TimelineScreen.flameChartSectionKey), findsNothing);
+      expect(find.byType(EventDetails), findsNothing);
+      expect(controller.allTraceEvents, isEmpty);
+
+      // Clear the full timeline.
+      await pumpTimelineScreen(tester, TimelineMode.full);
+      await tester.tap(find.byKey(TimelineScreen.recordButtonKey));
+      await tester.pump();
+      await tester.tap(find.byKey(TimelineScreen.stopRecordingButtonKey));
+      await tester.pump();
+      expect(find.byKey(TimelineScreen.recordingInstructionsKey), findsNothing);
+      expect(find.byKey(TimelineScreen.recordingStatusKey), findsNothing);
+      expect(
+        find.byKey(TimelineScreen.emptyTimelineRecordingKey),
+        findsOneWidget,
+      );
+
+      await tester.tap(find.byKey(TimelineScreen.clearButtonKey));
+      await tester.pump();
+      expect(
+        find.byKey(TimelineScreen.recordingInstructionsKey),
+        findsOneWidget,
+      );
+      expect(find.byKey(TimelineScreen.recordingStatusKey), findsNothing);
+      expect(
+        find.byKey(TimelineScreen.emptyTimelineRecordingKey),
+        findsNothing,
+      );
+
+      await resetWindowSize();
     });
   });
 }
diff --git a/packages/devtools_app/test/support/mocks.dart b/packages/devtools_app/test/support/mocks.dart
index 4b088f0..d4b05e2 100644
--- a/packages/devtools_app/test/support/mocks.dart
+++ b/packages/devtools_app/test/support/mocks.dart
@@ -5,9 +5,8 @@
 import 'dart:async';
 
 import 'package:devtools_app/src/connected_app.dart';
-
-import 'package:devtools_app/src/flutter/initializer.dart' as initializer;
 import 'package:devtools_app/src/flutter/controllers.dart';
+import 'package:devtools_app/src/flutter/initializer.dart' as initializer;
 import 'package:devtools_app/src/logging/logging_controller.dart';
 import 'package:devtools_app/src/service_extensions.dart' as extensions;
 import 'package:devtools_app/src/service_manager.dart';
@@ -103,6 +102,9 @@
       Future.value(TimelineFlags.parse(_vmTimelineFlags));
 
   @override
+  Future<Success> clearVMTimeline() => Future.value(Success());
+
+  @override
   Stream<Event> onEvent(String streamName) => const Stream.empty();
 
   @override
diff --git a/packages/devtools_app/test/timeline_model_test.dart b/packages/devtools_app/test/timeline_model_test.dart
index 0a44672..98e1ee7 100644
--- a/packages/devtools_app/test/timeline_model_test.dart
+++ b/packages/devtools_app/test/timeline_model_test.dart
@@ -65,7 +65,7 @@
     });
 
     test('displayDepth', () {
-      timelineData.selectedFrame = testFrame;
+      timelineData.selectedFrame = testFrame0;
       expect(timelineData.selectedFrame.uiEventFlow.depth, equals(7));
       expect(timelineData.selectedFrame.gpuEventFlow.depth, equals(2));
       expect(timelineData.displayDepth, equals(9));
@@ -74,9 +74,9 @@
     test('clear', () async {
       timelineData = FrameBasedTimelineData(displayRefreshRate: 120)
         ..traceEvents.add({'test': 'trace event'})
-        ..frames.add(testFrame)
+        ..frames.add(testFrame0)
         ..selectedEvent = vsyncEvent
-        ..selectedFrame = testFrame
+        ..selectedFrame = testFrame0
         ..cpuProfileData = CpuProfileData.parse(jsonDecode(jsonEncode({})));
       expect(timelineData.traceEvents, isNotEmpty);
       expect(timelineData.frames, isNotEmpty);
diff --git a/packages/devtools_testing/lib/support/timeline_test_data.dart b/packages/devtools_testing/lib/support/timeline_test_data.dart
index 3c01d41..624cb86 100644
--- a/packages/devtools_testing/lib/support/timeline_test_data.dart
+++ b/packages/devtools_testing/lib/support/timeline_test_data.dart
@@ -39,7 +39,11 @@
   'args': {}
 });
 
-final testFrame = TimelineFrame('id_0')
+final testFrame0 = TimelineFrame('id_0')
+  ..setEventFlow(goldenUiTimelineEvent)
+  ..setEventFlow(goldenGpuTimelineEvent);
+
+final testFrame1 = TimelineFrame('id_1')
   ..setEventFlow(goldenUiTimelineEvent)
   ..setEventFlow(goldenGpuTimelineEvent);
 
diff --git a/packages/devtools_testing/lib/timeline_controller_test.dart b/packages/devtools_testing/lib/timeline_controller_test.dart
index becce2e..4742ab5 100644
--- a/packages/devtools_testing/lib/timeline_controller_test.dart
+++ b/packages/devtools_testing/lib/timeline_controller_test.dart
@@ -90,7 +90,10 @@
       final offlineFullTimelineData =
           OfflineFullTimelineData.parse(offlineFullTimelineDataJson);
       timelineController.loadOfflineData(offlineFullTimelineData);
-      expect(timelineController.timelineMode, equals(TimelineMode.full));
+      expect(
+        timelineController.timelineModeNotifier.value,
+        equals(TimelineMode.full),
+      );
       expect(
         isFullTimelineDataEqual(
           timelineController.offlineTimelineData,
@@ -132,7 +135,8 @@
       expect(timelineController.timeline.data.selectedEvent, isNull);
       expect(timelineController.timeline.data.cpuProfileData, isNull);
       timelineController.selectTimelineEvent(vsyncEvent);
-      expect(timelineController.timeline.data.selectedEvent, equals(vsyncEvent));
+      expect(
+          timelineController.timeline.data.selectedEvent, equals(vsyncEvent));
 
       // Select a different frame.
       final frame_1 = TimelineFrame('id_1');
@@ -165,11 +169,11 @@
     });
 
     test('recording', () {
-      expect(timelineController.fullTimeline.recording, isFalse);
+      expect(timelineController.fullTimeline.recordingNotifier.value, isFalse);
       timelineController.fullTimeline.startRecording();
-      expect(timelineController.fullTimeline.recording, isTrue);
+      expect(timelineController.fullTimeline.recordingNotifier.value, isTrue);
       timelineController.fullTimeline.stopRecording();
-      expect(timelineController.fullTimeline.recording, isFalse);
+      expect(timelineController.fullTimeline.recordingNotifier.value, isFalse);
     });
   });
 }