Avoid conflating concept of duration in `TimeRange` util class (#9325)

`TimeRange` represents the range between two `start` and `end` times marked in microseconds (so a duration). However, it stored `start` and `end` as `Duration` as well, resulting in the existence of three durations which was a bit confusing as I worked to understand code using `TimeRange`.

Beyond that, `TimeRange` was not immutable so had a concept of being "well formed" (or having both a start and end), resulting in multiple checks for that state and many not-null assertion operations. To maintain the intermediate status and also reduce the necessity of the not-null assertions, split out the intermediate state to a new `TimeRangeBuilder` class with a `build` method that consolidates the not-null assertions to that singular function.
diff --git a/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart b/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart
index f894963..00d2da0 100644
--- a/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart
+++ b/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart
@@ -696,7 +696,7 @@
     // flex so that we can set a minimum width for small timing chunks.
     final timingWidgets = <Widget>[];
     for (final instant in data.instantEvents) {
-      final duration = instant.timeRange!.duration;
+      final duration = instant.timeRange.duration;
       timingWidgets.add(_buildTimingRow(nextColor(), instant.name, duration));
     }
     final duration = Duration(
@@ -714,14 +714,14 @@
     final data = this.data as DartIOHttpRequestData;
     final result = <Widget>[];
     for (final instant in data.instantEvents) {
-      final instantEventStart = data.instantEvents.first.timeRange!.start!;
-      final timeRange = instant.timeRange!;
+      final instantEventStart = data.instantEvents.first.timeRange.start;
+      final timeRange = instant.timeRange;
       final startDisplay = durationText(
-        timeRange.start! - instantEventStart,
+        Duration(microseconds: timeRange.start - instantEventStart),
         unit: DurationDisplayUnit.milliseconds,
       );
       final endDisplay = durationText(
-        timeRange.end! - instantEventStart,
+        Duration(microseconds: timeRange.end - instantEventStart),
         unit: DurationDisplayUnit.milliseconds,
       );
       final totalDisplay = durationText(
diff --git a/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frame_model.dart b/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frame_model.dart
index ee3e547..9b199db 100644
--- a/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frame_model.dart
+++ b/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frame_model.dart
@@ -2,8 +2,6 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
 
-import 'dart:math' as math;
-
 import '../../../../shared/primitives/utils.dart';
 import '../../performance_model.dart';
 import '../controls/enhance_tracing/enhance_tracing_model.dart';
@@ -24,12 +22,10 @@
   });
 
   factory FlutterFrame.fromJson(Map<String, Object?> json) {
-    final timeStart = Duration(microseconds: json[startTimeKey]! as int);
-    final timeEnd =
-        timeStart + Duration(microseconds: json[elapsedKey]! as int);
-    final frameTime = TimeRange()
-      ..start = timeStart
-      ..end = timeEnd;
+    final frameTime = TimeRange.ofDuration(
+      json[elapsedKey]! as int,
+      start: json[startTimeKey]! as int,
+    );
     return FlutterFrame._(
       id: json[numberKey]! as int,
       timeFromFrameTiming: frameTime,
@@ -58,14 +54,6 @@
   /// which the data was parsed.
   final TimeRange timeFromFrameTiming;
 
-  /// The time range of the Flutter frame based on the frame's
-  /// [timelineEventData], which contains timing information from the VM's
-  /// timeline events.
-  ///
-  /// This time range should be used for activities related to timeline events,
-  /// like scrolling a frame's timeline events into view, for example.
-  TimeRange get timeFromEventFlows => timelineEventData.time;
-
   /// Build time for this Flutter frame based on data from the FrameTiming API
   /// sent over the extension stream as 'Flutter.Frame' events.
   final Duration buildTime;
@@ -85,13 +73,12 @@
   /// (e.g. when the 'Flutter.Frame' event for this frame was received).
   ///
   /// If we did not have [EnhanceTracingState] information at the time that this
-  /// frame was drawn (e.g. the DevTools performancd page was not opened and
+  /// frame was drawn (e.g. the DevTools performance page was not opened and
   /// listening for frames yet), this value will be null.
   EnhanceTracingState? enhanceTracingState;
 
   FrameAnalysis? get frameAnalysis {
-    final frameAnalysis_ = _frameAnalysis;
-    if (frameAnalysis_ != null) return frameAnalysis_;
+    if (_frameAnalysis case final frameAnalysis?) return frameAnalysis;
     if (timelineEventData.isNotEmpty) {
       return _frameAnalysis = FrameAnalysis(this);
     }
@@ -104,15 +91,17 @@
 
   Duration get shaderDuration {
     if (_shaderTime != null) return _shaderTime!;
-    if (timelineEventData.rasterEvent == null) return Duration.zero;
-    final shaderEvents = timelineEventData.rasterEvent!
-        .shallowNodesWithCondition((event) => event.isShaderEvent);
-    final duration = shaderEvents.fold<Duration>(Duration.zero, (
-      previous,
-      event,
-    ) {
-      return previous + event.time.duration;
-    });
+    final rasterEvent = timelineEventData.rasterEvent;
+    if (rasterEvent == null) return Duration.zero;
+    final shaderEvents = rasterEvent.shallowNodesWithCondition(
+      (event) => event.isShaderEvent,
+    );
+    final duration = shaderEvents
+        .where((event) => event.isComplete)
+        .fold<Duration>(
+          Duration.zero,
+          (previous, event) => previous + event.time.duration,
+        );
     return _shaderTime = duration;
   }
 
@@ -150,7 +139,7 @@
 
   Map<String, Object?> get json => {
     numberKey: id,
-    startTimeKey: timeFromFrameTiming.start!.inMicroseconds,
+    startTimeKey: timeFromFrameTiming.start,
     elapsedKey: timeFromFrameTiming.duration.inMicroseconds,
     buildKey: buildTime.inMicroseconds,
     rasterKey: rasterTime.inMicroseconds,
@@ -197,42 +186,9 @@
 
   bool get isNotEmpty => uiEvent != null || rasterEvent != null;
 
-  final time = TimeRange();
-
-  void setEventFlow({
-    required FlutterTimelineEvent event,
-    bool setTimeData = true,
-  }) {
+  void setEventFlow({required FlutterTimelineEvent event}) {
     final type = event.type!;
     _eventFlows[type.index] = event;
-    if (setTimeData) {
-      if (type == TimelineEventType.ui) {
-        time.start = event.time.start;
-        // If [rasterEventFlow] has already completed, set the end time for this
-        // frame to [event]'s end time.
-        if (rasterEvent != null) {
-          time.end = event.time.end;
-        }
-      } else if (type == TimelineEventType.raster) {
-        // If [uiEventFlow] is null, that means that this raster event flow
-        // completed before the ui event flow did for this frame. This means one
-        // of two things: 1) there will never be a [uiEventFlow] for this frame
-        // because the UI events are not present in the available timeline
-        // events, or 2) the [uiEventFlow] has started but not completed yet. In
-        // the event that 2) is true, do not set the frame end time here because
-        // the end time for this frame will be set to the end time for
-        // [uiEventFlow] once it finishes.
-        final theUiEvent = uiEvent;
-        if (theUiEvent != null) {
-          time.end = Duration(
-            microseconds: math.max(
-              theUiEvent.time.end!.inMicroseconds,
-              event.time.end?.inMicroseconds ?? 0,
-            ),
-          );
-        }
-      }
-    }
   }
 
   FlutterTimelineEvent? eventByType(TimelineEventType type) {
diff --git a/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frames_controller.dart b/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frames_controller.dart
index 0c83414..ff97028 100644
--- a/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frames_controller.dart
+++ b/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frames_controller.dart
@@ -165,7 +165,7 @@
     assert(frame.isWellFormed);
     firstWellFormedFrameMicros = math.min(
       firstWellFormedFrameMicros ?? maxJsInt,
-      frame.timeFromFrameTiming.start!.inMicroseconds,
+      frame.timeFromFrameTiming.start,
     );
   }
 
diff --git a/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_analysis_model.dart b/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_analysis_model.dart
index eaa84ac..d1316e4 100644
--- a/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_analysis_model.dart
+++ b/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_analysis_model.dart
@@ -101,10 +101,7 @@
   ///
   /// This is drawn from all events for this frame from the raster thread.
   late FramePhase rasterPhase = FramePhase.raster(
-    events: [
-      if (frame.timelineEventData.rasterEvent != null)
-        frame.timelineEventData.rasterEvent!,
-    ],
+    events: [?frame.timelineEventData.rasterEvent],
   );
 
   late FramePhase longestUiPhase = _calculateLongestFramePhase();
@@ -276,9 +273,12 @@
     : title = type.display,
       duration =
           duration ??
-          events.fold<Duration>(Duration.zero, (previous, event) {
-            return previous + event.time.duration;
-          });
+          events
+              .where((event) => event.isComplete)
+              .fold<Duration>(
+                Duration.zero,
+                (previous, event) => previous + event.time.duration,
+              );
 
   factory FramePhase.build({
     required List<FlutterTimelineEvent> events,
diff --git a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/_perfetto_web.dart b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/_perfetto_web.dart
index 6cfcc20..2d5cafa 100644
--- a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/_perfetto_web.dart
+++ b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/_perfetto_web.dart
@@ -19,7 +19,6 @@
 import '../../../../../shared/globals.dart';
 import '../../../../../shared/primitives/utils.dart';
 import '../../../../../shared/utils/utils.dart';
-import '../../../performance_utils.dart';
 import '_perfetto_controller_web.dart';
 import 'perfetto_controller.dart';
 
@@ -205,10 +204,6 @@
   Future<void> _scrollToTimeRange(TimeRange? timeRange) async {
     if (timeRange == null) return;
 
-    if (!timeRange.isWellFormed) {
-      pushNoTimelineEventsAvailableWarning();
-      return;
-    }
     await _pingPerfettoUntilReady();
     ga.select(
       gac.performance,
@@ -217,8 +212,8 @@
     _postMessage({
       'perfetto': {
         // Pass the values to Perfetto in seconds.
-        'timeStart': timeRange.start!.inMicroseconds / 1000000,
-        'timeEnd': timeRange.end!.inMicroseconds / 1000000,
+        'timeStart': timeRange.start / 1000000,
+        'timeEnd': timeRange.end / 1000000,
         // The time range should take up 80% of the visible window.
         'viewPercentage': 0.8,
       },
diff --git a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_event_processor.dart b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_event_processor.dart
index a6b4e4c..8e9daae 100644
--- a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_event_processor.dart
+++ b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_event_processor.dart
@@ -116,8 +116,7 @@
 
     // Since this event is complete, move back up the tree to the nearest
     // incomplete event.
-    while (current!.parent != null &&
-        current.parent!.time.end?.inMicroseconds != null) {
+    while (current!.parent?.isComplete ?? false) {
       current = current.parent;
     }
     currentTimelineEventsByTrackId[trackId] = current.parent;
diff --git a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_controller.dart b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_controller.dart
index 8c12811..5383078 100644
--- a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_controller.dart
+++ b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_controller.dart
@@ -436,9 +436,7 @@
       );
     }
 
-    if (frame.timeFromFrameTiming.isWellFormed) {
-      perfettoController.scrollToTimeRange(frame.timeFromFrameTiming);
-    }
+    perfettoController.scrollToTimeRange(frame.timeFromFrameTiming);
   }
 
   void addTimelineEvent(FlutterTimelineEvent event) {
@@ -462,7 +460,7 @@
       } else {
         final unassignedEventsForFrame = _unassignedFlutterTimelineEvents
             .putIfAbsent(frameNumber, () => FrameTimelineEventData());
-        unassignedEventsForFrame.setEventFlow(event: event, setTimeData: false);
+        unassignedEventsForFrame.setEventFlow(event: event);
       }
     }
   }
diff --git a/packages/devtools_app/lib/src/screens/performance/performance_model.dart b/packages/devtools_app/lib/src/screens/performance/performance_model.dart
index fbd4b4e..637eae3 100644
--- a/packages/devtools_app/lib/src/screens/performance/performance_model.dart
+++ b/packages/devtools_app/lib/src/screens/performance/performance_model.dart
@@ -98,11 +98,18 @@
 }
 
 class FlutterTimelineEvent extends TreeNode<FlutterTimelineEvent> {
-  FlutterTimelineEvent(PerfettoTrackEvent firstTrackEvent)
-    : trackEvents = [firstTrackEvent],
-      type = firstTrackEvent.timelineEventType {
-    time.start = Duration(microseconds: firstTrackEvent.timestampMicros);
-  }
+  factory FlutterTimelineEvent(PerfettoTrackEvent firstTrackEvent) =>
+      FlutterTimelineEvent._(
+        trackEvents: [firstTrackEvent],
+        type: firstTrackEvent.timelineEventType,
+        timeBuilder: TimeRangeBuilder(start: firstTrackEvent.timestampMicros),
+      );
+
+  FlutterTimelineEvent._({
+    required this.trackEvents,
+    required this.type,
+    required TimeRangeBuilder timeBuilder,
+  }) : _timeBuilder = timeBuilder;
 
   static const rasterEventName = 'Rasterizer::DoDraw';
   static const uiEventName = 'Animator::BeginFrame';
@@ -110,9 +117,17 @@
   /// Perfetto track events associated with this [FlutterTimelineEvent].
   final List<PerfettoTrackEvent> trackEvents;
 
-  TimelineEventType? type;
+  final TimelineEventType? type;
 
-  TimeRange time = TimeRange();
+  final TimeRangeBuilder _timeBuilder;
+
+  /// The time range of this event.
+  ///
+  /// Throws if [isComplete] is false.
+  TimeRange get time => _timeBuilder.build();
+
+  /// Whether this event is complete and has received an end track event.
+  bool get isComplete => _timeBuilder.canBuild;
 
   String? get name => trackEvents.first.name;
 
@@ -123,26 +138,17 @@
   bool get isShaderEvent =>
       trackEvents.first.isShaderEvent || trackEvents.last.isShaderEvent;
 
-  bool get isWellFormed => time.start != null && time.end != null;
-
   void addEndTrackEvent(PerfettoTrackEvent event) {
-    time.end = Duration(microseconds: event.timestampMicros);
+    _timeBuilder.end = event.timestampMicros;
     trackEvents.add(event);
   }
 
   @override
-  FlutterTimelineEvent shallowCopy() {
-    final copy = FlutterTimelineEvent(trackEvents.first);
-    for (int i = 1; i < trackEvents.length; i++) {
-      copy.trackEvents.add(trackEvents[i]);
-    }
-    copy
-      ..type = type
-      ..time = (TimeRange()
-        ..start = time.start
-        ..end = time.end);
-    return copy;
-  }
+  FlutterTimelineEvent shallowCopy() => FlutterTimelineEvent._(
+    trackEvents: trackEvents.toList(),
+    type: type,
+    timeBuilder: _timeBuilder.copy(),
+  );
 
   @visibleForTesting
   FlutterTimelineEvent deepCopy() {
@@ -174,7 +180,12 @@
   }
 
   void format(StringBuffer buf, String indent) {
-    buf.writeln('$indent$name $time');
+    buf.write('$indent$name');
+    if (isComplete) {
+      buf.write(time);
+    }
+
+    buf.writeln(' ');
     for (final child in children) {
       child.format(buf, '  $indent');
     }
diff --git a/packages/devtools_app/lib/src/screens/profiler/cpu_profile_model.dart b/packages/devtools_app/lib/src/screens/profiler/cpu_profile_model.dart
index 33413d7..f3c4297 100644
--- a/packages/devtools_app/lib/src/screens/profiler/cpu_profile_model.dart
+++ b/packages/devtools_app/lib/src/screens/profiler/cpu_profile_model.dart
@@ -196,11 +196,7 @@
       samplePeriod: samplePeriod,
       stackDepth: json.stackDepth ?? 0,
       time: (timeOriginMicros != null && timeExtentMicros != null)
-          ? (TimeRange()
-              ..start = Duration(microseconds: timeOriginMicros)
-              ..end = Duration(
-                microseconds: timeOriginMicros + timeExtentMicros,
-              ))
+          ? TimeRange.ofDuration(timeExtentMicros, start: timeOriginMicros)
           : null,
     );
 
@@ -246,11 +242,7 @@
     // Each sample in [subSamples] will have the leaf stack
     // frame id for a cpu sample within [subTimeRange].
     final subSamples = superProfile.cpuSamples
-        .where(
-          (sample) => subTimeRange.contains(
-            Duration(microseconds: sample.timestampMicros!),
-          ),
-        )
+        .where((sample) => subTimeRange.contains(sample.timestampMicros!))
         .toList();
 
     final subStackFrames = <String, CpuStackFrame>{};
@@ -399,13 +391,12 @@
       // for this profile data, and the samples included in this data could be
       // sparse over the original profile's time range, so true start and end
       // times wouldn't be helpful.
-      time: TimeRange()
-        ..start = const Duration()
-        ..end = Duration(
-          microseconds: microsPerSample.isInfinite
-              ? 0
-              : (newSampleCount * microsPerSample).round(),
-        ),
+      time: TimeRange(
+        start: 0,
+        end: microsPerSample.isInfinite
+            ? 0
+            : (newSampleCount * microsPerSample).round(),
+      ),
     );
 
     final stackFramesWithTag = <String, CpuStackFrame>{};
@@ -504,13 +495,12 @@
       // for this profile data, and the samples included in this data could be
       // sparse over the original profile's time range, so true start and end
       // times wouldn't be helpful.
-      time: TimeRange()
-        ..start = const Duration()
-        ..end = Duration(
-          microseconds: microsPerSample.isInfinite || microsPerSample.isNaN
-              ? 0
-              : (filteredCpuSamples.length * microsPerSample).round(),
-        ),
+      time: TimeRange(
+        start: 0,
+        end: microsPerSample.isInfinite || microsPerSample.isNaN
+            ? 0
+            : (filteredCpuSamples.length * microsPerSample).round(),
+      ),
     );
 
     void walkAndFilter(CpuStackFrame stackFrame) {
@@ -748,10 +738,8 @@
     _samplePeriodKey: profileMetaData.samplePeriod,
     _sampleCountKey: profileMetaData.sampleCount,
     _stackDepthKey: profileMetaData.stackDepth,
-    if (profileMetaData.time?.start case final startTime?)
-      _timeOriginKey: startTime.inMicroseconds,
-    if (profileMetaData.time?.duration case final duration?)
-      _timeExtentKey: duration.inMicroseconds,
+    _timeOriginKey: ?profileMetaData.time?.start,
+    _timeExtentKey: ?profileMetaData.time?.duration.inMicroseconds,
     _stackFramesKey: stackFramesJson,
     _traceEventsKey: cpuSamples.map((sample) => sample.toJson).toList(),
   };
@@ -1119,7 +1107,7 @@
       return _profilesByLabel[label];
     }
 
-    if (!time!.isWellFormed) return null;
+    if (time == null) return null;
 
     // If we have a profile for a time range encompassing [time], then we can
     // generate and cache the profile for [time] without needing to pull data
diff --git a/packages/devtools_app/lib/src/shared/charts/flame_chart.dart b/packages/devtools_app/lib/src/shared/charts/flame_chart.dart
index e0eab38..37200e0 100644
--- a/packages/devtools_app/lib/src/shared/charts/flame_chart.dart
+++ b/packages/devtools_app/lib/src/shared/charts/flame_chart.dart
@@ -172,9 +172,7 @@
             currentZoom /
             startingPxPerMicro;
 
-    return TimeRange()
-      ..start = Duration(microseconds: startMicros.round())
-      ..end = Duration(microseconds: endMicros.round());
+    return TimeRange(start: startMicros.round(), end: endMicros.round());
   }
 
   /// Starting pixels per microsecond in order to fit all the data in view at
@@ -182,7 +180,7 @@
   double get startingPxPerMicro =>
       widget.startingContentWidth / widget.time.duration.inMicroseconds;
 
-  int get startTimeOffset => widget.time.start!.inMicroseconds;
+  int get startTimeOffset => widget.time.start;
 
   double get maxZoomLevel {
     // The max zoom level is hit when 1 microsecond is the width of each grid
diff --git a/packages/devtools_app/lib/src/shared/http/http_request_data.dart b/packages/devtools_app/lib/src/shared/http/http_request_data.dart
index 7b6c505..8be9458 100644
--- a/packages/devtools_app/lib/src/shared/http/http_request_data.dart
+++ b/packages/devtools_app/lib/src/shared/http/http_request_data.dart
@@ -30,10 +30,10 @@
   DateTime get timestamp => _event.timestamp;
 
   /// The amount of time since the last instant event completed.
-  TimeRange? get timeRange => _timeRange;
+  TimeRange get timeRange => _timeRangeBuilder.build();
 
-  // This is set from within HttpRequestData.
-  TimeRange? _timeRange;
+  // This is modified from within HttpRequestData.
+  final TimeRangeBuilder _timeRangeBuilder = TimeRangeBuilder();
 }
 
 /// An abstraction of an HTTP request made through dart:io.
@@ -339,9 +339,9 @@
     DateTime lastTime = _request.startTime;
     for (final instant in instantEvents) {
       final instantTime = instant.timestamp;
-      instant._timeRange = TimeRange()
-        ..start = Duration(microseconds: lastTime.microsecondsSinceEpoch)
-        ..end = Duration(microseconds: instantTime.microsecondsSinceEpoch);
+      instant._timeRangeBuilder
+        ..start = lastTime.microsecondsSinceEpoch
+        ..end = instantTime.microsecondsSinceEpoch;
       lastTime = instantTime;
     }
   }
diff --git a/packages/devtools_app/lib/src/shared/primitives/utils.dart b/packages/devtools_app/lib/src/shared/primitives/utils.dart
index b25709c..3da3540 100644
--- a/packages/devtools_app/lib/src/shared/primitives/utils.dart
+++ b/packages/devtools_app/lib/src/shared/primitives/utils.dart
@@ -442,84 +442,101 @@
   }
 }
 
+// If the need arises, this enum can be expanded to include any of the
+// remaining time units supported by [Duration] - (seconds, minutes, etc.).
 /// Time unit for displaying time ranges.
-///
-/// If the need arises, this enum can be expanded to include any of the
-/// remaining time units supported by [Duration] - (seconds, minutes, etc.). If
-/// you add a unit of time to this enum, modify the toString() method in
-/// [TimeRange] to handle the new case.
 enum TimeUnit { microseconds, milliseconds }
 
-class TimeRange {
-  TimeRange({this.singleAssignment = true});
+/// A builder used to build a well-formed [TimeRange] incrementally.
+final class TimeRangeBuilder {
+  /// Creates a new [TimeRangeBuilder] to build a [TimeRange]
+  /// with the optionally specified [start] and [end] initial values.
+  TimeRangeBuilder({int? start, int? end}) : _start = start, _end = end;
 
-  factory TimeRange.offset({
-    required TimeRange original,
-    required Duration offset,
-  }) {
-    final originalStart = original.start;
-    final originalEnd = original.end;
-    return TimeRange()
-      ..start = originalStart != null ? originalStart + offset : null
-      ..end = originalEnd != null ? originalEnd + offset : null;
+  int? _start;
+
+  /// Sets the start time of this builder.
+  ///
+  /// The start time should be less than or equal to the end time.
+  set start(int startTime) {
+    _start = startTime;
   }
 
-  final bool singleAssignment;
+  int? _end;
 
-  Duration? get start => _start;
+  /// Sets the end time of this builder.
+  ///
+  /// The end time should be greater than or equal to the start time.
+  set end(int endTime) {
+    _end = endTime;
+  }
 
-  Duration? _start;
+  /// Whether both `start` and `end` properties are set,
+  /// meaning [build] can safely be called.
+  bool get canBuild => _start != null && _end != null;
 
-  set start(Duration? value) {
-    if (singleAssignment) {
-      assert(_start == null);
+  /// Returns a [TimeRange] built from the specified [start] and [end] values.
+  ///
+  /// If either [start] or [end] is `null`, throws an error.
+  ///
+  /// The [start] time must be less than or equal to the [end] time.
+  TimeRange build() {
+    final startTimestamp = _start;
+    if (startTimestamp == null) {
+      throw StateError('TimeRangeBuilder.start must be set before building!');
     }
-    if (value != null && _end != null) {
+
+    final endTimestamp = _end;
+    if (endTimestamp == null) {
+      throw StateError('TimeRangeBuilder.end must be set before building!');
+    }
+
+    return TimeRange(start: startTimestamp, end: endTimestamp);
+  }
+
+  /// Returns a new [TimeRangeBuilder] with the current values of this builder.
+  TimeRangeBuilder copy() => TimeRangeBuilder(start: _start, end: _end);
+}
+
+final class TimeRange {
+  /// Creates a [TimeRange] with the specified
+  /// [start] and [end] times in microseconds.
+  ///
+  /// The [start] time must be less than or equal to the [end] time.
+  TimeRange({required this.start, required this.end})
+    : assert(start <= end, '$start is not less than or equal to end time $end'),
       assert(
-        value <= _end!,
-        '$value is not less than or equal to end time $_end',
+        end >= start,
+        '$end is not greater than or equal to start time $start',
       );
-    }
-    _start = value;
-  }
 
-  Duration? get end => _end;
+  /// Creates a [TimeRange] with the specified [start] time in microseconds and
+  /// [end] calculated as being [duration] microseconds later.
+  factory TimeRange.ofDuration(int duration, {int start = 0}) =>
+      TimeRange(start: start, end: start + duration);
 
-  Duration? _end;
+  /// The starting time in microseconds.
+  final int start;
 
-  set end(Duration? value) {
-    if (singleAssignment) {
-      assert(_end == null);
-    }
-    if (value != null && _start != null) {
-      assert(
-        value >= _start!,
-        '$value is not greater than or equal to start time $_start',
-      );
-    }
-    _end = value;
-  }
+  /// The ending time in microseconds.
+  final int end;
 
-  Duration get duration => end! - start!;
+  /// The duration of time between the [start] and [end] microseconds.
+  Duration get duration => Duration(microseconds: end - start);
 
-  bool contains(Duration target) => start! <= target && end! >= target;
+  /// Whether this time range contains the specified [target] microsecond.
+  bool contains(int target) => start <= target && end >= target;
 
-  bool containsRange(TimeRange t) => start! <= t.start! && end! >= t.end!;
-
-  bool overlaps(TimeRange t) => t.end! > start! && t.start! < end!;
-
-  bool get isWellFormed => _start != null && _end != null;
+  /// Whether this time range completely contains the
+  /// specified [target] time range.
+  bool containsRange(TimeRange target) =>
+      start <= target.start && end >= target.end;
 
   @override
-  String toString({TimeUnit? unit}) {
-    unit ??= TimeUnit.microseconds;
-    switch (unit) {
-      case TimeUnit.microseconds:
-        return '[${_start?.inMicroseconds} μs - ${end?.inMicroseconds} μs]';
-      case TimeUnit.milliseconds:
-        return '[${_start?.inMilliseconds} ms - ${end?.inMilliseconds} ms]';
-    }
-  }
+  String toString({TimeUnit? unit}) => switch (unit ?? TimeUnit.microseconds) {
+    TimeUnit.microseconds => '[$start μs - $end μs]',
+    TimeUnit.milliseconds => '[${start ~/ 1000} ms - ${end ~/ 1000} ms]',
+  };
 
   @override
   bool operator ==(Object other) {
diff --git a/packages/devtools_app/test/screens/cpu_profiler/cpu_profile_model_test.dart b/packages/devtools_app/test/screens/cpu_profiler/cpu_profile_model_test.dart
index 18601f5..4f4759a 100644
--- a/packages/devtools_app/test/screens/cpu_profiler/cpu_profile_model_test.dart
+++ b/packages/devtools_app/test/screens/cpu_profiler/cpu_profile_model_test.dart
@@ -33,15 +33,12 @@
       final cpuProfileEmptyData = CpuProfileData.fromJson(
         cpuProfileResponseEmptyJson,
       );
-      expect(
-        cpuProfileEmptyData.profileMetaData.time!.end!.inMilliseconds,
-        47377796,
-      );
+      expect(cpuProfileEmptyData.profileMetaData.time!.end, 47377796685);
       final filtered = CpuProfileData.filterFrom(
         cpuProfileEmptyData,
         (_) => true,
       );
-      expect(filtered.profileMetaData.time!.end!.inMilliseconds, 0);
+      expect(filtered.profileMetaData.time!.end, 0);
     });
 
     test('init from parse', () {
@@ -55,22 +52,14 @@
       );
       expect(cpuProfileData.profileMetaData.sampleCount, equals(8));
       expect(cpuProfileData.profileMetaData.samplePeriod, equals(50));
-      expect(
-        cpuProfileData.profileMetaData.time!.start!.inMicroseconds,
-        equals(47377796685),
-      );
-      expect(
-        cpuProfileData.profileMetaData.time!.end!.inMicroseconds,
-        equals(47377799685),
-      );
+      expect(cpuProfileData.profileMetaData.time!.start, equals(47377796685));
+      expect(cpuProfileData.profileMetaData.time!.end, equals(47377799685));
     });
 
     test('subProfile', () {
       final subProfile = CpuProfileData.subProfile(
         cpuProfileData,
-        TimeRange()
-          ..start = const Duration(microseconds: 47377796685)
-          ..end = const Duration(microseconds: 47377799063),
+        TimeRange(start: 47377796685, end: 47377799063),
       );
 
       expect(subProfile.stackFramesJson, equals(subProfileStackFrames));
diff --git a/packages/devtools_app/test/screens/cpu_profiler/cpu_profiler_test.dart b/packages/devtools_app/test/screens/cpu_profiler/cpu_profiler_test.dart
index 8185362..65ef96b 100644
--- a/packages/devtools_app/test/screens/cpu_profiler/cpu_profiler_test.dart
+++ b/packages/devtools_app/test/screens/cpu_profiler/cpu_profiler_test.dart
@@ -659,9 +659,7 @@
         sampleCount: 100,
         samplePeriod: 100,
         stackDepth: 128,
-        time: TimeRange()
-          ..start = const Duration()
-          ..end = const Duration(microseconds: 10000),
+        time: TimeRange.ofDuration(10000),
       );
       await tester.pumpWidget(wrap(CpuProfileStats(metadata: metadata)));
       await tester.pumpAndSettle();
@@ -698,9 +696,7 @@
         sampleCount: 100,
         samplePeriod: 0,
         stackDepth: 128,
-        time: TimeRange()
-          ..start = const Duration()
-          ..end = const Duration(microseconds: 10000),
+        time: TimeRange.ofDuration(10000),
       );
       await tester.pumpWidget(wrap(CpuProfileStats(metadata: metadata)));
       await tester.pumpAndSettle();
diff --git a/packages/devtools_app/test/screens/cpu_profiler/method_table/method_table_model_test.dart b/packages/devtools_app/test/screens/cpu_profiler/method_table/method_table_model_test.dart
index ebe54bd..5645be9 100644
--- a/packages/devtools_app/test/screens/cpu_profiler/method_table/method_table_model_test.dart
+++ b/packages/devtools_app/test/screens/cpu_profiler/method_table/method_table_model_test.dart
@@ -18,9 +18,10 @@
       profileMetaData: ProfileMetaData(
         sampleCount: 10,
         samplePeriod: 250,
-        time: TimeRange()
-          ..start = Duration.zero
-          ..end = const Duration(seconds: 1),
+        time: TimeRange(
+          start: 0,
+          end: const Duration(seconds: 1).inMicroseconds,
+        ),
       ),
       stackFrameIds: {'1'},
     );
@@ -33,9 +34,10 @@
       profileMetaData: ProfileMetaData(
         sampleCount: 10,
         samplePeriod: 250,
-        time: TimeRange()
-          ..start = Duration.zero
-          ..end = const Duration(seconds: 1),
+        time: TimeRange(
+          start: 0,
+          end: const Duration(seconds: 1).inMicroseconds,
+        ),
       ),
       stackFrameIds: {'2'},
     );
@@ -48,9 +50,10 @@
       profileMetaData: ProfileMetaData(
         sampleCount: 10,
         samplePeriod: 250,
-        time: TimeRange()
-          ..start = Duration.zero
-          ..end = const Duration(seconds: 1),
+        time: TimeRange(
+          start: 0,
+          end: const Duration(seconds: 1).inMicroseconds,
+        ),
       ),
       stackFrameIds: {'3'},
     );
@@ -63,9 +66,10 @@
       profileMetaData: ProfileMetaData(
         sampleCount: 10,
         samplePeriod: 250,
-        time: TimeRange()
-          ..start = Duration.zero
-          ..end = const Duration(seconds: 1),
+        time: TimeRange(
+          start: 0,
+          end: const Duration(seconds: 1).inMicroseconds,
+        ),
       ),
       stackFrameIds: {'4'},
     );
diff --git a/packages/devtools_app/test/shared/primitives/utils_test.dart b/packages/devtools_app/test/shared/primitives/utils_test.dart
index 267b22f..3fe14a5 100644
--- a/packages/devtools_app/test/shared/primitives/utils_test.dart
+++ b/packages/devtools_app/test/shared/primitives/utils_test.dart
@@ -252,13 +252,7 @@
 
     group('TimeRange', () {
       test('toString', () {
-        final timeRange = TimeRange();
-
-        expect(timeRange.toString(), equals('[null μs - null μs]'));
-
-        timeRange
-          ..start = const Duration(microseconds: 1000)
-          ..end = const Duration(microseconds: 8000);
+        final timeRange = TimeRange(start: 1000, end: 8000);
 
         expect(timeRange.duration.inMicroseconds, equals(7000));
         expect(timeRange.toString(), equals('[1000 μs - 8000 μs]'));
@@ -268,53 +262,13 @@
         );
       });
 
-      test('overlaps', () {
-        final t = TimeRange()
-          ..start = const Duration(milliseconds: 100)
-          ..end = const Duration(milliseconds: 200);
-        final overlapBeginning = TimeRange()
-          ..start = const Duration(milliseconds: 50)
-          ..end = const Duration(milliseconds: 150);
-        final overlapMiddle = TimeRange()
-          ..start = const Duration(milliseconds: 125)
-          ..end = const Duration(milliseconds: 175);
-        final overlapEnd = TimeRange()
-          ..start = const Duration(milliseconds: 150)
-          ..end = const Duration(milliseconds: 250);
-        final overlapAll = TimeRange()
-          ..start = const Duration(milliseconds: 50)
-          ..end = const Duration(milliseconds: 250);
-        final noOverlap = TimeRange()
-          ..start = const Duration(milliseconds: 300)
-          ..end = const Duration(milliseconds: 400);
-
-        expect(t.overlaps(t), isTrue);
-        expect(t.overlaps(overlapBeginning), isTrue);
-        expect(t.overlaps(overlapMiddle), isTrue);
-        expect(t.overlaps(overlapEnd), isTrue);
-        expect(t.overlaps(overlapAll), isTrue);
-        expect(t.overlaps(noOverlap), isFalse);
-      });
-
       test('containsRange', () {
-        final t = TimeRange()
-          ..start = const Duration(milliseconds: 100)
-          ..end = const Duration(milliseconds: 200);
-        final containsStart = TimeRange()
-          ..start = const Duration(milliseconds: 50)
-          ..end = const Duration(milliseconds: 150);
-        final containsStartAndEnd = TimeRange()
-          ..start = const Duration(milliseconds: 125)
-          ..end = const Duration(milliseconds: 175);
-        final containsEnd = TimeRange()
-          ..start = const Duration(milliseconds: 150)
-          ..end = const Duration(milliseconds: 250);
-        final invertedContains = TimeRange()
-          ..start = const Duration(milliseconds: 50)
-          ..end = const Duration(milliseconds: 250);
-        final containsNeither = TimeRange()
-          ..start = const Duration(milliseconds: 300)
-          ..end = const Duration(milliseconds: 400);
+        final t = TimeRange(start: 100, end: 200);
+        final containsStart = TimeRange(start: 50, end: 150);
+        final containsStartAndEnd = TimeRange(start: 125, end: 175);
+        final containsEnd = TimeRange(start: 150, end: 250);
+        final invertedContains = TimeRange(start: 50, end: 250);
+        final containsNeither = TimeRange(start: 300, end: 400);
 
         expect(t.containsRange(containsStart), isFalse);
         expect(t.containsRange(containsStartAndEnd), isTrue);
@@ -323,90 +277,59 @@
         expect(t.containsRange(containsNeither), isFalse);
       });
 
-      test('start setter throws exception when single assignment is true', () {
+      test('throws exception when start is after end', () {
         expect(() {
-          final t = TimeRange()..start = Duration.zero;
-          t.start = Duration.zero;
+          TimeRange(start: 2000, end: 1000);
         }, throwsAssertionError);
       });
+    });
 
-      test('start setter throws exception when value is after end', () {
-        expect(() {
-          final t = TimeRange()..end = const Duration(seconds: 1);
-          t.start = const Duration(seconds: 2);
-        }, throwsAssertionError);
-      });
-
-      test('end setter throws exception when single assignment is true', () {
-        expect(() {
-          final t = TimeRange()..end = Duration.zero;
-          t.end = Duration.zero;
-        }, throwsAssertionError);
-      });
-
-      test('end setter throws exception when value is before start', () {
-        expect(() {
-          final t = TimeRange()..start = const Duration(seconds: 1);
-          t.end = Duration.zero;
-        }, throwsAssertionError);
-      });
-
-      test('isWellFormed', () {
+    group('TimeRangeBuilder', () {
+      test('throws if start or end are not set', () {
+        expect(() => TimeRangeBuilder().build(), throwsStateError);
+        expect(() => TimeRangeBuilder(start: 1000).build(), throwsStateError);
+        expect(() => TimeRangeBuilder(end: 1000).build(), throwsStateError);
         expect(
-          (TimeRange()
-                ..start = Duration.zero
-                ..end = Duration.zero)
-              .isWellFormed,
-          isTrue,
+          () => (TimeRangeBuilder()..start = 1000).build(),
+          throwsStateError,
         );
-        expect((TimeRange()..end = Duration.zero).isWellFormed, isFalse);
-        expect((TimeRange()..start = Duration.zero).isWellFormed, isFalse);
+        expect(
+          () => (TimeRangeBuilder()..end = 1000).build(),
+          throwsStateError,
+        );
       });
 
-      group('offset', () {
-        test('from well formed time range', () {
-          final t = TimeRange()
-            ..start = const Duration(milliseconds: 100)
-            ..end = const Duration(milliseconds: 200);
-          final offset = TimeRange.offset(
-            original: t,
-            offset: const Duration(milliseconds: 300),
-          );
+      test('throws error if start is after end', () {
+        expect(
+          () => TimeRangeBuilder(start: 2000, end: 1000).build(),
+          throwsAssertionError,
+        );
+      });
 
-          expect(offset.start, equals(const Duration(milliseconds: 400)));
-          expect(offset.end, equals(const Duration(milliseconds: 500)));
-        });
-
-        test('from half formed time range', () {
-          var t = TimeRange()..start = const Duration(milliseconds: 100);
-          var offset = TimeRange.offset(
-            original: t,
-            offset: const Duration(milliseconds: 300),
-          );
-
-          expect(offset.start, equals(const Duration(milliseconds: 400)));
-          expect(offset.end, isNull);
-
-          t = TimeRange()..end = const Duration(milliseconds: 200);
-          offset = TimeRange.offset(
-            original: t,
-            offset: const Duration(milliseconds: 300),
-          );
-
-          expect(offset.start, isNull);
-          expect(offset.end, equals(const Duration(milliseconds: 500)));
-        });
-
-        test('from empty time range', () {
-          final t = TimeRange();
-          final offset = TimeRange.offset(
-            original: t,
-            offset: const Duration(milliseconds: 300),
-          );
-
-          expect(offset.start, isNull);
-          expect(offset.end, isNull);
-        });
+      test('builds expected TimeRange', () {
+        expect(
+          TimeRangeBuilder(start: 1000, end: 2000).build(),
+          TimeRange(start: 1000, end: 2000),
+        );
+        expect(
+          TimeRangeBuilder(start: 1000, end: 1000).build(),
+          TimeRange(start: 1000, end: 1000),
+        );
+        expect(
+          (TimeRangeBuilder(start: 150)..end = 250).build(),
+          TimeRange(start: 150, end: 250),
+        );
+        expect(
+          (TimeRangeBuilder(end: 400)..start = 300).build(),
+          TimeRange(start: 300, end: 400),
+        );
+        expect(
+          (TimeRangeBuilder()
+                ..start = 0
+                ..end = 100)
+              .build(),
+          TimeRange.ofDuration(100),
+        );
       });
     });
 
diff --git a/packages/devtools_app/test/test_infra/test_data/cpu_profiler/cpu_profile.dart b/packages/devtools_app/test/test_infra/test_data/cpu_profiler/cpu_profile.dart
index df193d7..7e13acf 100644
--- a/packages/devtools_app/test/test_infra/test_data/cpu_profiler/cpu_profile.dart
+++ b/packages/devtools_app/test/test_infra/test_data/cpu_profiler/cpu_profile.dart
@@ -1211,11 +1211,12 @@
   sampleCount: 10,
   samplePeriod: 1000,
   stackDepth: 128,
-  time: TimeRange()
-    ..start = const Duration()
+  time: TimeRange(
+    start: 0,
     // Note this intentionally adds 10000 microseconds more than what
     // was measured, regression test for Issue #8870.
-    ..end = const Duration(microseconds: 20000),
+    end: 20000,
+  ),
 );
 
 final tagFrameA = CpuStackFrame(
@@ -1529,9 +1530,7 @@
   sampleCount: 0,
   samplePeriod: 50,
   stackDepth: 128,
-  time: TimeRange()
-    ..start = const Duration()
-    ..end = const Duration(microseconds: 100),
+  time: TimeRange.ofDuration(100),
 );
 
 final zeroStackFrame = CpuStackFrame(