blob: 3cf85a29306da17d50e5768d5a88633d50ca492e [file] [log] [blame]
// 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 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../logic/qualified_task.dart';
import '../model/commit.pb.dart';
import '../model/task.pb.dart';
import '../state/build.dart';
import 'luci_task_attempt_summary.dart';
import 'now.dart';
import 'progress_button.dart';
import 'task_box.dart';
class TaskOverlayEntryPositionDelegate extends SingleChildLayoutDelegate {
TaskOverlayEntryPositionDelegate(this.target);
/// The offset of the target the tooltip is positioned near in the global
/// coordinate system.
final Offset target;
static Offset positionDependentBox({
required Size size,
required Size childSize,
required Offset target,
}) {
const double margin = 10.0;
const double verticalOffset = TaskBox.cellSize * .9;
// VERTICAL DIRECTION
final bool fitsBelow = target.dy + verticalOffset + childSize.height <= size.height - margin;
double y;
if (fitsBelow) {
y = math.min(target.dy + verticalOffset, size.height - margin);
} else {
y = math.max(target.dy - childSize.height, margin);
}
// HORIZONTAL DIRECTION
double x;
// The whole size isn't big enough, just center it.
if (size.width - margin * 2.0 < childSize.width) {
x = (size.width - childSize.width) / 2.0;
} else {
final double normalizedTargetX = (target.dx).clamp(margin, size.width - margin);
final double edge = normalizedTargetX + childSize.width;
// Position the box as close to the left edge of the full size
// without going over the margin.
if (edge > size.width) {
x = size.width - margin - childSize.width;
} else {
x = normalizedTargetX;
}
}
return Offset(x, y);
}
@override
Offset getPositionForChild(Size size, Size childSize) {
return positionDependentBox(
size: size,
childSize: childSize,
target: target,
);
}
@override
bool shouldRelayout(TaskOverlayEntryPositionDelegate oldDelegate) {
return oldDelegate.target != target;
}
}
/// Displays the information from [Task] and allows interacting with a [Task].
///
/// This is intended to be inserted in an [OverlayEntry] as it requires
/// [closeCallback] that will remove the widget from the tree.
class TaskOverlayEntry extends StatelessWidget {
const TaskOverlayEntry({
super.key,
required this.position,
required this.task,
required this.showSnackBarCallback,
required this.closeCallback,
required this.buildState,
required this.commit,
});
/// The global position where to show the task overlay.
final Offset position;
/// The [Task] to display in the overlay.
final Task task;
final ShowSnackBarCallback showSnackBarCallback;
/// This callback removes the parent overlay from the widget tree.
///
/// On a click that is outside the area of the overlay (the rest of the screen),
/// this callback is called closing the overlay.
final VoidCallback closeCallback;
/// A reference to the [BuildState] for performing operations on this [Task].
final BuildState buildState;
/// [Commit] for cirrus tasks to show log.
final Commit commit;
@override
Widget build(BuildContext context) {
// If this is ever positioned not at the top-left of the viewport, then
// we should make sure to convert the position to the Overlay's coordinate
// space otherwise it'll be misaligned.
return Stack(
children: <Widget>[
// This is a focus container to emphasize the cell that this
// [Overlay] is currently showing information from.
Positioned(
top: position.dy,
left: position.dx,
width: TaskBox.cellSize,
height: TaskBox.cellSize,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.black, width: 4.0),
color: Colors.white70,
),
),
),
// This is the area a user can click (the rest of the screen) to close the overlay.
GestureDetector(
onTap: closeCallback,
behavior: HitTestBehavior.opaque,
child: const SizedBox.expand(),
),
Positioned(
// Move this overlay to be where the parent is
child: CustomSingleChildLayout(
delegate: TaskOverlayEntryPositionDelegate(position),
child: TaskOverlayContents(
showSnackBarCallback: showSnackBarCallback,
buildState: buildState,
task: task,
commit: commit,
closeCallback: closeCallback,
),
),
),
],
);
}
}
/// Displays the information from [Task] and allows interacting with a [Task].
///
/// This is intended to be inserted in [TaskOverlayEntry].
///
/// Offers the functionality of opening the log for this [Task] and rerunning
/// this [Task] through the build system.
class TaskOverlayContents extends StatelessWidget {
const TaskOverlayContents({
super.key,
required this.showSnackBarCallback,
required this.buildState,
required this.task,
required this.closeCallback,
this.commit,
});
final ShowSnackBarCallback showSnackBarCallback;
/// A reference to the [BuildState] for performing operations on this [Task].
final BuildState buildState;
/// The [Task] to display in the overlay
final Task task;
/// [Commit] for cirrus tasks to show log.
final Commit? commit;
/// This callback removes the parent overlay from the widget tree.
///
/// This is used in this scope to close this overlay on redirection to view
/// the agent for this task in the agent dashboard.
final void Function() closeCallback;
@visibleForTesting
static const String rerunErrorMessage = 'Failed to rerun task.';
@visibleForTesting
static const String rerunSuccessMessage = 'Devicelab is rerunning the task. This can take a minute to propagate.';
@visibleForTesting
static const Duration rerunSnackBarDuration = Duration(seconds: 15);
/// A lookup table to define the [Icon] for this task, based on
/// the values returned by [TaskBox.effectiveTaskStatus].
static const Map<String, Icon> statusIcon = <String, Icon>{
TaskBox.statusFailed: Icon(Icons.clear, color: Colors.red, size: 32),
TaskBox.statusNew: Icon(Icons.new_releases, color: Colors.blue, size: 32),
TaskBox.statusInProgress: Icon(Icons.autorenew, color: Colors.blue, size: 32),
TaskBox.statusSucceeded: Icon(Icons.check_circle, color: Colors.green, size: 32),
};
@override
Widget build(BuildContext context) {
final QualifiedTask qualifiedTask = QualifiedTask.fromTask(task);
final DateTime? now = Now.of(context);
final DateTime createTime = DateTime.fromMillisecondsSinceEpoch(task.createTimestamp.toInt());
final DateTime startTime = DateTime.fromMillisecondsSinceEpoch(task.startTimestamp.toInt());
final DateTime endTime = DateTime.fromMillisecondsSinceEpoch(task.endTimestamp.toInt());
final Duration queueDuration =
task.startTimestamp == 0 ? now!.difference(createTime) : startTime.difference(createTime);
final Duration runDuration = task.endTimestamp == 0 ? now!.difference(startTime) : endTime.difference(startTime);
/// There are 2 possible states for queue time:
/// 1. Task is waiting to be scheduled (in queue)
/// 2. Task has been scheduled (out of queue)
final String queueText = (task.status != TaskBox.statusNew)
? 'Queue time: ${queueDuration.inMinutes} minutes'
: 'Queueing for ${queueDuration.inMinutes} minutes';
/// There are 3 possible states for the runtime:
/// 1. Task has not run yet (new)
/// 2. Task is running (in progress)
/// 3. Task ran (other status)
final String runText = (task.status == TaskBox.statusInProgress)
? 'Running for ${runDuration.inMinutes} minutes'
: (task.status != TaskBox.statusNew)
? 'Run time: ${runDuration.inMinutes} minutes'
: '';
final String summaryText = <String>[
'Attempts: ${task.attempts}',
if (runText.isNotEmpty) runText,
queueText,
if (task.isFlaky) 'Flaky: ${task.isFlaky}',
].join('\n');
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Tooltip(
message: task.status,
child: Padding(
padding: const EdgeInsets.only(left: 8.0, top: 10.0, right: 12.0),
child: statusIcon[task.status],
),
),
Expanded(
child: ListBody(
children: <Widget>[
SelectableText(
task.name,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(
summaryText,
style: Theme.of(context).textTheme.bodyMedium,
),
if (QualifiedTask.fromTask(task).isLuci) LuciTaskAttemptSummary(task: task),
],
),
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: ProgressButton(
child: const Text('VIEW LOGS'),
onPressed: () => launchUrl(Uri.parse(logUrl(task, commit: commit))),
),
),
if (qualifiedTask.isLuci)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: ProgressButton(
onPressed: _rerunTask,
child: const Text('RERUN'),
),
),
],
),
],
),
),
),
);
}
Future<void> _rerunTask() async {
final bool rerunResponse = await buildState.rerunTask(task);
if (rerunResponse) {
showSnackBarCallback(
const SnackBar(
content: Text(rerunSuccessMessage),
duration: rerunSnackBarDuration,
),
);
}
}
}