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); }); }); }