blob: 3e5c25f72541e97d2856fe8c1ef4edf6d5b07a1a [file] [log] [blame]
// Copyright (c) 2018 The Chromium 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:math' as math;
import 'package:flutter/material.dart';
import '../entities.dart';
import '../providers.dart';
import '../utils/framework.dart';
import '../utils/semantics.dart';
import 'details.dart';
class BuildPage extends StatelessWidget {
const BuildPage();
@override
Widget build(BuildContext context) {
var buildStatusModel = BuildStatusProvider.of(context);
var buildBrokenModel = BuildBrokenProvider.of(context);
return RequestOnce(
callback: () {
buildStatusModel.requestBuildStatus();
buildBrokenModel.requestStatus();
},
child: BuildPageBody(
requestBuildStatus: buildStatusModel.requestBuildStatus,
requestStatus: buildBrokenModel.requestStatus,
loaded: buildStatusModel.isLoaded,
lastCommit: buildStatusModel.lastCommit,
statuses: buildStatusModel.statuses ?? const [],
broken: buildBrokenModel.isBuildBroken,
),
);
}
}
class BuildPageBody extends StatelessWidget {
const BuildPageBody({
@required this.requestBuildStatus,
@required this.requestStatus,
@required this.loaded,
@required this.lastCommit,
@required this.statuses,
@required this.broken,
});
final Future<void> Function({bool force}) requestBuildStatus;
final Future<void> Function() requestStatus;
final bool loaded;
final bool broken;
final CommitInfo lastCommit;
final List<BuildStatus> statuses;
@override
Widget build(BuildContext context) {
var slivers = <Widget>[
SliverAppBar(
title: const Text('Build History'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
tooltip: 'Accounts',
onPressed: () {
Navigator.of(context).pushNamed('accounts');
},
),
],
elevation: 0,
floating: true,
),
SliverPersistentHeader(
delegate: BuildHeaderDelegate(
broken: broken,
loaded: loaded,
sha: lastCommit?.sha,
),
pinned: true,
),
const SliverToBoxAdapter(
child: Divider(
height: 1,
)),
];
if (!loaded) {
slivers.add(const SliverFillRemaining(
child: Center(
child: CircularProgressIndicator(),
),
));
} else {
var childCount = statuses.length * 2;
slivers.add(
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index.isOdd) {
return const Divider(height: 1);
} else {
return BuildSummary(data: statuses[index ~/ 2]);
}
},
childCount: childCount,
semanticIndexCallback: evenSemanticIndexes,
),
),
);
}
return DecoratedBox(
decoration: BoxDecoration(color: Theme.of(context).primaryColorDark),
child: SafeArea(
child: DecoratedBox(
decoration: BoxDecoration(color: Theme.of(context).canvasColor),
child: RefreshIndicator(
onRefresh: () async {
requestStatus();
await requestBuildStatus(force: true);
Scaffold.of(context).showSnackBar(const SnackBar(content: Text('Build updated')));
},
child: CustomScrollView(
semanticChildCount: statuses.length,
slivers: slivers,
),
),
),
),
);
}
}
/// The build status shown as a shrinking header.
class BuildHeaderDelegate extends SliverPersistentHeaderDelegate {
const BuildHeaderDelegate({
@required this.broken,
this.loaded = true,
this.sha,
});
/// Whether the build is currently broken.
final bool broken;
/// Whether the data required to display this widget is still loading.
final bool loaded;
/// The SHA of the last known commit.
///
/// Null if this commit is unknown (loading is true).
final String sha;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
var theme = Theme.of(context);
if (!loaded) {
return const SizedBox(height: 0);
}
var shortSha = sha.substring(0, 6);
var title = broken
? Text('flutter/flutter: broken at $shortSha', style: theme.textTheme.title.copyWith(color: Colors.white))
: Text('flutter/flutter: fixed at $shortSha', style: theme.textTheme.title.copyWith(color: Colors.white));
var height = math.max(maxExtent - shrinkOffset, minExtent);
return Material(
elevation: overlapsContent ? 4 : 0,
color: broken ? Colors.red : theme.primaryColorDark,
child: Container(
height: height,
width: double.infinity,
padding: const EdgeInsets.fromLTRB(12, 0, 12, 0),
alignment: Alignment.centerLeft,
child: title,
),
);
}
@override
double get maxExtent => 100;
@override
double get minExtent => 40;
@override
bool shouldRebuild(covariant BuildHeaderDelegate oldDelegate) {
return oldDelegate.broken != broken || oldDelegate.sha != sha || oldDelegate.loaded != loaded;
}
}
class BuildSummary extends StatelessWidget {
const BuildSummary({this.data});
final BuildStatus data;
@override
Widget build(BuildContext context) {
var clockModel = ClockProvider.of(context);
var theme = Theme.of(context);
String time;
var currentTime = clockModel.currentTime();
var commitTime = data.checklist.checklist.createTimestamp;
// hack
if (currentTime.weekday != commitTime.weekday && currentTime.day != commitTime.day) {
time = 'on ${commitTime.month}/${commitTime.day}';
} else {
var hour = commitTime.hour;
var minute = commitTime.minute;
var isPm = hour >= 12;
if (hour > 12) {
hour -= 12;
}
time = 'at $hour:${minute < 10 ? '0$minute' : minute} ${isPm ? 'PM' : 'AM'}';
}
return ListTile(
onTap: () {
var navigator = Navigator.of(context);
navigator.push<void>(MaterialPageRoute(builder: (context) {
return BuildDetailsPage(data: data);
}));
},
leading: SizedBox(
width: 36,
height: 36,
child: Hero(
tag: data,
child: CircleAvatar(
backgroundColor: theme.canvasColor,
backgroundImage: NetworkImage(data.checklist.checklist.commit.author.avatarUrl),
),
),
),
title: Text(
'flutter/${data.checklist.checklist.commit.sha.substring(0, 6)}',
),
subtitle: Text('Submitted by ${data.checklist.checklist.commit.author.login} $time'),
isThreeLine: false,
dense: true,
trailing: BuildBox(data: data),
);
}
}
class BuildBox extends StatelessWidget {
const BuildBox({this.data});
final BuildStatus data;
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
var flakyFailureCount = 0;
var failedCount = 0;
var pendingCount = 0;
for (var stage in data.stages) {
for (var task in stage.tasks) {
if (task.task.status == 'Failed') {
if (task.task.isFlaky) {
flakyFailureCount += 1;
}
failedCount += 1;
} else if (task.task.status == 'In Progress') {
pendingCount += 1;
}
}
}
Widget result;
if (failedCount == 0 && (pendingCount == 0) ) {
result = BuildStatusBox(Icons.done, 'Passing', Colors.green);
} else if (failedCount == 0) {
result = BuildStatusBox(Icons.watch_later, 'Running', theme.accentColor);
} else if (flakyFailureCount == failedCount) {
result = BuildStatusBox(Icons.warning, 'Flaky', Colors.orange);
} else {
result = BuildStatusBox(Icons.error_outline, 'Failing', Colors.redAccent);
}
if (pendingCount > 0) {
return Semantics(
label: 'In Progress',
child: PendingBox(child: result),
);
}
return result;
}
}
class BuildStatusBox extends Container {
BuildStatusBox(
IconData iconData, String semanticLabel, Color backgroundColor)
: super(
width: 36.0,
height: 36.0,
child: Center(
child: Icon(iconData, size: 24.0, color: Colors.white, semanticLabel: semanticLabel),
),
decoration: BoxDecoration(
color: backgroundColor,
border: Border.all(color: Colors.black54)));
}
class PendingBox extends StatefulWidget {
const PendingBox({@required this.child});
final Widget child;
@override
_PendingBoxState createState() => _PendingBoxState();
}
class _PendingBoxState extends State<PendingBox> with SingleTickerProviderStateMixin {
AnimationController _controller;
Animation<double> _opacity;
@override
void initState() {
_controller = AnimationController(vsync: this, duration: const Duration(seconds: 2));
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
_controller.reverse();
} else if (status == AnimationStatus.dismissed) {
_controller.forward();
}
});
_controller.addListener(() {
setState(() {});
});
_opacity = Tween<double>(begin: 0.8, end: 0.1).animate(_controller);
_controller.forward();
super.initState();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _opacity,
child: widget.child,
);
}
}