| // Copyright 2019 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'package:flutter/material.dart'; |
| import 'package:provider/provider.dart'; |
| |
| import 'package:cocoon_service/protos.dart' show CommitStatus, Commit, Stage, Task; |
| |
| import '../logic/qualified_task.dart'; |
| import '../state/build.dart'; |
| import 'commit_box.dart'; |
| import 'lattice.dart'; |
| import 'pulse.dart'; |
| import 'task_box.dart'; |
| import 'task_icon.dart'; |
| import 'task_overlay.dart'; |
| |
| /// Container that manages the layout and data handling for [TaskGrid]. |
| /// |
| /// If there's no data for [TaskGrid], it shows [CircularProgressIndicator]. |
| class TaskGridContainer extends StatelessWidget { |
| const TaskGridContainer({Key key}) : super(key: key); |
| |
| @visibleForTesting |
| static const String errorFetchCommitStatus = 'An error occurred fetching commit statuses'; |
| @visibleForTesting |
| static const String errorFetchTreeStatus = 'An error occurred fetching tree build status'; |
| @visibleForTesting |
| static const Duration errorSnackbarDuration = Duration(seconds: 8); |
| |
| @override |
| Widget build(BuildContext context) { |
| final BuildState buildState = Provider.of<BuildState>(context); |
| return AnimatedBuilder( |
| animation: buildState, |
| builder: (BuildContext context, Widget child) { |
| final List<CommitStatus> commitStatuses = buildState.statuses; |
| |
| // Assume if there is no data that it is loading. |
| if (commitStatuses.isEmpty) { |
| return const Center( |
| child: CircularProgressIndicator(), |
| ); |
| } |
| |
| return TaskGrid( |
| buildState: buildState, |
| commitStatuses: commitStatuses, |
| ); |
| }, |
| ); |
| } |
| } |
| |
| /// Display results from flutter/flutter repository's continuous integration. |
| /// |
| /// Results are displayed in a matrix format. Rows are commits and columns |
| /// are the results from tasks. |
| class TaskGrid extends StatefulWidget { |
| const TaskGrid({ |
| Key key, |
| // TODO(ianh): We really shouldn't take both of these, since buildState exposes status as well; |
| // it's asking for trouble because the tests can (and do) describe a mutually inconsistent state. |
| @required this.buildState, |
| @required this.commitStatuses, |
| }) : super(key: key); |
| |
| /// The build status data to display in the grid. |
| final List<CommitStatus> commitStatuses; |
| |
| /// Reference to the build state to perform actions on [TaskMatrix], like rerunning tasks. |
| final BuildState buildState; |
| |
| @override |
| State<TaskGrid> createState() => _TaskGridState(); |
| } |
| |
| class _TaskGridState extends State<TaskGrid> { |
| // TODO(ianh): Cache the lattice cells. Right now we are regenerating the entire |
| // lattice matrix each time the task grid has to update, regardless of whether |
| // we've received new data or not. |
| |
| @override |
| Widget build(BuildContext context) { |
| return LatticeScrollView( |
| // TODO(ianh): Provide some vertical scroll physics that disable |
| // the clamping in the vertical direction, so that you can keep |
| // scrolling past the end instead of hitting a wall every time |
| // we load. |
| // TODO(ianh): Trigger the loading from the scroll offset, |
| // rather than the current hack of loading during build. |
| cells: _processCommitStatuses(widget.commitStatuses), |
| cellSize: const Size.square(TaskBox.cellSize), |
| ); |
| } |
| |
| static const Map<String, double> _statusScores = <String, double>{ |
| TaskBox.statusFailed: 5.0, |
| TaskBox.statusUnderperformed: 4.5, |
| TaskBox.statusInProgress: 1.0, |
| TaskBox.statusNew: 1.0, |
| TaskBox.statusSkipped: 0.0, |
| TaskBox.statusSucceeded: 0.0, |
| }; |
| |
| /// This is the logic for turning the raw data from the [BuildState] object, a list of |
| /// [CommitStatus] objects, into the data that describes the rendering as used by the |
| /// [LatticeScrollView], a list of lists of [LatticeCell]s. |
| /// |
| /// The process is as follows: |
| /// |
| /// 1. We create `rows`, a list of [_Row] objects which are used to temporarily |
| /// represent each row in the data, where a row basically represents a [Commit]. |
| /// |
| /// These are derived from th `commitStatuses` directly -- each [CommitStatus] is one |
| /// row, representing one [Commit] and all its [Task]s. |
| /// |
| /// 2. We walk the `commitStatuses` again, examining each [Task] of each [CommitStatus], |
| /// |
| /// For the first 25 rows, we compute a score for each task, one commit at a time, so |
| /// that we'll be able to sort the tasks later. The score is based on [_statusScores] |
| /// (the map defined above). Each row is weighted in the score proportional to how |
| /// far from the first row it is, so the first row has a weight of 1.0, the second a |
| /// weight of 1/2, the third a weight of 1/3, etc. |
| /// |
| /// Then, we update the `rows` list to contain a [LatticeCell] for this task on this |
| /// commit. The color of the square is derived from [_painterFor], the builder, if |
| /// any, is derived from [_builderFor], and the tap handler from [_tapHandlerFor]. |
| /// |
| /// 3. We create a list that represents all the tasks we've seen so far, sorted by |
| /// their score (tie-breaking on task names). |
| /// |
| /// 4. Finally, we generate the output, by putting together all the data collected in |
| /// the second step, walking the tasks in the order determined in the third step. |
| // |
| // TODO(ianh): Find a way to save the majority of the work done each time we build the |
| // matrix. If you've scrolled down several thousand rows, you don't want to have to |
| // rebuild the entire matrix each time you load another 25 rows. |
| List<List<LatticeCell>> _processCommitStatuses(List<CommitStatus> commitStatuses) { |
| // 1: PREPARE ROWS |
| final List<_Row> rows = commitStatuses.map<_Row>((CommitStatus commitStatus) => _Row(commitStatus.commit)).toList(); |
| // 2: WALK ALL TASKS |
| final Map<QualifiedTask, double> scores = <QualifiedTask, double>{}; |
| int commitCount = 0; |
| for (final CommitStatus status in commitStatuses) { |
| commitCount += 1; |
| for (final Stage stage in status.stages) { |
| for (final Task task in stage.tasks) { |
| final QualifiedTask qualifiedTask = QualifiedTask(task.stageName, task.name); |
| if (commitCount <= 25) { |
| double score = 0.0; |
| if (task.attempts > 1) { |
| score += 1.0; |
| } |
| if (_statusScores.containsKey(task.status)) { |
| score += _statusScores[task.status]; |
| } |
| if (task.isFlaky) { |
| score /= 2.0; |
| } |
| score /= commitCount; |
| scores.update( |
| qualifiedTask, |
| (double value) => value += score, |
| ifAbsent: () => score, |
| ); |
| } else { |
| // In case we have a task that doesn't exist in the first 25 rows, |
| // we still push the task into the table of scores. Otherwise, we |
| // won't know how to sort the task later. |
| scores.putIfAbsent( |
| qualifiedTask, |
| () => 0.0, |
| ); |
| } |
| rows[commitCount - 1].cells[qualifiedTask] = LatticeCell( |
| painter: _painterFor(task), |
| builder: _builderFor(task), |
| onTap: _tapHandlerFor(status.commit, task), |
| ); |
| } |
| } |
| } |
| // 3: SORT |
| final List<QualifiedTask> tasks = scores.keys.toList() |
| ..sort((QualifiedTask a, QualifiedTask b) { |
| final int scoreComparison = scores[b].compareTo(scores[a]); |
| if (scoreComparison != 0) { |
| return scoreComparison; |
| } |
| // If the scores are identical, break ties on the name of the task. |
| // We do that because otherwise the sort order isn't stable. |
| if (a.stage != b.stage) { |
| return a.stage.compareTo(b.stage); |
| } |
| return a.task.compareTo(b.task); |
| }); |
| // 4: GENERATE RESULTING LIST OF LISTS |
| return <List<LatticeCell>>[ |
| <LatticeCell>[ |
| const LatticeCell(), |
| ...tasks.map<LatticeCell>((QualifiedTask task) => LatticeCell( |
| builder: (BuildContext context) => TaskIcon(qualifiedTask: task), |
| )), |
| ], |
| ...rows.map<List<LatticeCell>>( |
| (_Row row) => <LatticeCell>[ |
| LatticeCell( |
| builder: (BuildContext context) => CommitBox(commit: row.commit), |
| ), |
| ...tasks.map<LatticeCell>((QualifiedTask task) => row.cells[task] ?? const LatticeCell()), |
| ], |
| ), |
| if (widget.buildState.moreStatusesExist) _generateLoadingRow(tasks.length + 1), |
| ]; |
| } |
| |
| static final Paint white = Paint()..color = Colors.white; |
| |
| Painter _painterFor(Task task) { |
| final String status = TaskBox.effectiveTaskStatus(task); |
| final Paint paint = Paint() |
| ..color = TaskBox.statusColor.containsKey(status) ? TaskBox.statusColor[status] : Colors.black; |
| if (task.isFlaky) { |
| paint.style = PaintingStyle.stroke; |
| paint.strokeWidth = 2.0; |
| return (Canvas canvas, Rect rect) { |
| canvas.drawRect(rect.deflate(6.0), paint); |
| }; |
| } |
| return (Canvas canvas, Rect rect) { |
| canvas.drawRect(rect.deflate(2.0), paint); |
| if (task.status == TaskBox.statusInProgress) { |
| canvas.drawCircle(rect.center, (rect.shortestSide / 2.0) - 6.0, white); |
| } |
| }; |
| } |
| |
| WidgetBuilder _builderFor(Task task) { |
| if (task.status == TaskBox.statusInProgress) { |
| return (BuildContext context) => Padding( |
| padding: const EdgeInsets.all(8.0), |
| child: Pulse(color: Colors.blue.shade900), |
| ); |
| } |
| return null; |
| } |
| |
| static final List<String> _loadingMessage = |
| 'LOADING... '.runes.map<String>((int codepoint) => String.fromCharCode(codepoint)).toList(); |
| |
| static const TextStyle loadingStyle = TextStyle( |
| fontSize: TaskBox.cellSize * 0.9, |
| fontWeight: FontWeight.w900, |
| ); |
| |
| List<LatticeCell> _generateLoadingRow(int length) { |
| return List<LatticeCell>.generate(length, (int index) { |
| final String character = _loadingMessage[index % _loadingMessage.length]; |
| return LatticeCell( |
| builder: (BuildContext context) { |
| widget.buildState.fetchMoreCommitStatuses(); // This is safe to call many times. |
| return Text( |
| character, |
| style: loadingStyle, |
| textAlign: TextAlign.center, |
| ); |
| }, |
| ); |
| }); |
| } |
| |
| OverlayEntry _taskOverlay; |
| |
| LatticeTapCallback _tapHandlerFor(Commit commit, Task task) { |
| return (Offset localPosition) { |
| _taskOverlay?.remove(); |
| _taskOverlay = OverlayEntry( |
| builder: (BuildContext context) => TaskOverlayEntry( |
| position: (this.context.findRenderObject() as RenderBox) |
| .localToGlobal(localPosition, ancestor: Overlay.of(context).context.findRenderObject()), |
| task: task, |
| showSnackBarCallback: Scaffold.of(this.context).showSnackBar, |
| closeCallback: _closeOverlay, |
| buildState: widget.buildState, |
| commit: commit, |
| ), |
| ); |
| Overlay.of(context).insert(_taskOverlay); |
| }; |
| } |
| |
| void _closeOverlay() { |
| _taskOverlay.remove(); |
| _taskOverlay = null; |
| } |
| } |
| |
| class _Row { |
| _Row(this.commit); |
| final Commit commit; |
| final Map<QualifiedTask, LatticeCell> cells = <QualifiedTask, LatticeCell>{}; |
| } |