blob: 64c1411e55ea42c12c2cf52a1f6953f602284e07 [file] [log] [blame]
// Copyright 2019 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:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'scaffold.dart';
import 'theme.dart';
import 'ui/label.dart';
const tooltipWait = Duration(milliseconds: 500);
/// The width of the package:flutter_test debugger device.
const debuggerDeviceWidth = 800.0;
const mediumDeviceWidth = 1000.0;
const defaultDialogRadius = 20.0;
const areaPaneHeaderHeight = 36.0;
/// Convenience [Divider] with [Padding] that provides a good divider in forms.
class PaddedDivider extends StatelessWidget {
const PaddedDivider({
Key key,
this.padding = const EdgeInsets.only(bottom: 10.0),
}) : super(key: key);
const PaddedDivider.thin({Key key})
: padding = const EdgeInsets.only(bottom: 4.0);
/// The padding to place around the divider.
final EdgeInsets padding;
@override
Widget build(BuildContext context) {
return Padding(
padding: padding,
child: const Divider(thickness: 1.0),
);
}
}
/// Creates a semibold version of [style].
TextStyle semibold(TextStyle style) =>
style.copyWith(fontWeight: FontWeight.w600);
/// Creates a version of [style] that uses the primary color of [context].
///
/// When the app is in dark mode, it instead uses the accent color.
TextStyle primaryColor(TextStyle style, BuildContext context) {
final theme = Theme.of(context);
return style.copyWith(
color: (theme.brightness == Brightness.light)
? theme.primaryColor
: theme.accentColor,
fontWeight: FontWeight.w400,
);
}
/// Creates a version of [style] that uses the lighter primary color of
/// [context].
///
/// In dark mode, the light primary color still has enough contrast to be
/// visible, so we continue to use it.
TextStyle primaryColorLight(TextStyle style, BuildContext context) {
final theme = Theme.of(context);
return style.copyWith(
color: theme.primaryColorLight,
fontWeight: FontWeight.w300,
);
}
// TODO(kenz): Cleanup - audit the following methods and convert them into
// Widgets where possible.
/// Button to clear data in the UI.
///
/// * `includeTextWidth`: The minimum width the button can be before the text is
/// omitted.
/// * `onPressed`: The callback to be called upon pressing the button.
StatelessWidget clearButton({
Key key,
bool busy = false,
double includeTextWidth,
@required VoidCallback onPressed,
}) {
return OutlineButton(
key: key,
onPressed: busy ? null : onPressed,
child: MaterialIconLabel(
Icons.block,
'Clear',
includeTextWidth: includeTextWidth,
),
);
}
/// Button to start recording data.
///
/// * `recording`: Whether recording is in progress.
/// * `includeTextWidth`: The minimum width the button can be before the text is
/// omitted.
/// * `labelOverride`: Optional alternative text to use for the button.
/// * `onPressed`: The callback to be called upon pressing the button.
StatelessWidget recordButton({
Key key,
@required bool recording,
double includeTextWidth,
String labelOverride,
@required VoidCallback onPressed,
}) {
return OutlineButton(
key: key,
onPressed: recording ? null : onPressed,
child: MaterialIconLabel(
Icons.fiber_manual_record,
labelOverride ?? 'Record',
includeTextWidth: includeTextWidth,
),
);
}
/// Button to refresh data.
///
/// * `includeTextWidth`: The minimum width the button can be before the text is
/// omitted.
/// * `labelOverride`: Optional alternative text to use for the button.
/// * `onPressed`: The callback to be called upon pressing the button.
StatelessWidget refreshButton({
Key key,
bool busy = false,
double includeTextWidth,
String labelOverride,
@required VoidCallback onPressed,
}) {
return OutlineButton(
key: key,
onPressed: busy ? null : onPressed,
child: MaterialIconLabel(
Icons.refresh,
'Refresh',
includeTextWidth: includeTextWidth,
),
);
}
/// Button to stop recording data.
///
/// * `recording`: Whether recording is in progress.
/// * `includeTextWidth`: The minimum width the button can be before the text is
/// omitted.
/// * `onPressed`: The callback to be called upon pressing the button.
StatelessWidget stopRecordingButton({
Key key,
@required bool recording,
double includeTextWidth,
@required VoidCallback onPressed,
}) {
return OutlineButton(
key: key,
onPressed: !recording ? null : onPressed,
child: MaterialIconLabel(
Icons.stop,
'Stop',
includeTextWidth: includeTextWidth,
),
);
}
/// Button to stop recording data.
///
/// * `paused`: Whether recording is in progress.
/// * `includeTextWidth`: The minimum width the button can be before the text is
/// omitted.
/// * `onPressed`: The callback to be called upon pressing the button.
StatelessWidget stopButton({
Key key,
@required bool paused,
double includeTextWidth,
@required VoidCallback onPressed,
}) {
return OutlineButton(
key: key,
onPressed: paused ? null : onPressed,
child: MaterialIconLabel(
Icons.stop,
'Stop',
includeTextWidth: includeTextWidth,
),
);
}
// TODO(kenz): make recording info its own stateful widget that handles
// listening to value notifiers and building info.
Widget recordingInfo({
Key instructionsKey,
Key recordingStatusKey,
Key processingStatusKey,
@required bool recording,
@required String recordedObject,
@required bool processing,
double progressValue,
bool isPause = false,
}) {
Widget child;
if (processing) {
child = processingInfo(
key: processingStatusKey,
progressValue: progressValue,
processedObject: recordedObject,
);
} else if (recording) {
child = _recordingStatus(
key: recordingStatusKey,
recordedObject: recordedObject,
);
} else {
child = _recordingInstructions(
key: instructionsKey,
recordedObject: recordedObject,
isPause: isPause,
);
}
return Center(
child: child,
);
}
Widget _recordingInstructions({Key key, String recordedObject, bool isPause}) {
final stopOrPauseRow = Row(
mainAxisAlignment: MainAxisAlignment.center,
children: isPause
? const [
Text('Click the pause button '),
Icon(Icons.pause),
Text(' to pause the recording.'),
]
: const [
Text('Click the stop button '),
Icon(Icons.stop),
Text(' to end the recording.'),
],
);
return Column(
key: key,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Click the record button '),
const Icon(Icons.fiber_manual_record),
Text(' to start recording $recordedObject.')
],
),
stopOrPauseRow,
],
);
}
Widget _recordingStatus({Key key, String recordedObject}) {
return Column(
key: key,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Recording $recordedObject'),
const SizedBox(height: defaultSpacing),
const CircularProgressIndicator(),
],
);
}
Widget processingInfo({
Key key,
@required double progressValue,
@required String processedObject,
}) {
return Center(
child: Column(
key: key,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Processing $processedObject'),
const SizedBox(height: defaultSpacing),
SizedBox(
width: 200.0,
height: defaultSpacing,
child: LinearProgressIndicator(
value: progressValue,
),
),
],
),
);
}
/// Common button for exiting offline mode.
///
/// Consumers of this widget will be responsible for including the following in
/// onPressed:
///
/// setState(() {
/// offlineMode = false;
/// }
Widget exitOfflineButton(FutureOr<void> Function() onPressed) {
return OutlineButton(
key: const Key('exit offline button'),
onPressed: onPressed,
child: const MaterialIconLabel(
Icons.clear,
'Exit offline mode',
),
);
}
/// Display a single bullet character in order to act as a stylized spacer
/// component.
class BulletSpacer extends StatelessWidget {
const BulletSpacer({this.useAccentColor = false});
final bool useAccentColor;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
TextTheme textTheme;
if (useAccentColor) {
textTheme = theme.appBarTheme.textTheme ?? theme.primaryTextTheme;
} else {
textTheme = theme.textTheme;
}
final textStyle = textTheme.bodyText2;
final mutedColor = textStyle?.color?.withAlpha(0x90);
return Container(
width: DevToolsScaffold.actionWidgetSize / 2,
height: DevToolsScaffold.actionWidgetSize,
alignment: Alignment.center,
child: Text(
'•',
style: textStyle?.copyWith(color: mutedColor),
),
);
}
}
/// A small element containing some accessory information, often a numeric
/// value.
class Badge extends StatelessWidget {
const Badge(this.text);
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// These constants are sized to give 1 digit badges a circular look.
const badgeCornerRadius = 12.0;
const badgePadding = 6.0;
return Container(
decoration: BoxDecoration(
color: theme.primaryColor,
borderRadius: BorderRadius.circular(badgeCornerRadius),
),
padding: const EdgeInsets.symmetric(
vertical: borderPadding,
horizontal: badgePadding,
),
child: Text(
text,
// Use a slightly smaller font for the badge.
style: theme.primaryTextTheme.bodyText2.apply(fontSizeDelta: -1),
),
);
}
}
/// A widget, commonly used for icon buttons, that provides a tooltip with a
/// common delay before the tooltip is shown.
class ActionButton extends StatelessWidget {
const ActionButton({
@required this.tooltip,
@required this.child,
});
final String tooltip;
final Widget child;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
waitDuration: tooltipWait,
child: child,
);
}
}
/// A wrapper around a FlatButton, an Icon, and an optional Tooltip; used for
/// small toolbar actions.
class ToolbarAction extends StatelessWidget {
const ToolbarAction({
@required this.icon,
@required this.onPressed,
this.tooltip,
Key key,
}) : super(key: key);
final IconData icon;
final String tooltip;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final button = FlatButton(
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onPressed: onPressed,
child: Icon(icon, size: actionsIconSize),
);
return tooltip == null
? button
: Tooltip(
message: tooltip,
waitDuration: tooltipWait,
child: button,
);
}
}
/// Create a bordered, fixed-height header area with a title and optional child
/// on the right-hand side.
///
/// This is typically used as a title for a logical area of the screen.
// TODO(devoncarew): Refactor this into an 'AreaPaneHeader' widget.
SizedBox areaPaneHeader(
BuildContext context, {
@required String title,
bool needsTopBorder = true,
List<Widget> actions = const [],
double rightPadding = densePadding,
}) {
final theme = Theme.of(context);
return SizedBox(
height: areaPaneHeaderHeight,
child: Container(
decoration: BoxDecoration(
border: Border(
top: needsTopBorder
? BorderSide(color: theme.focusColor)
: BorderSide.none,
bottom: BorderSide(color: theme.focusColor),
),
color: titleSolidBackgroundColor(theme),
),
padding: EdgeInsets.only(left: defaultSpacing, right: rightPadding),
alignment: Alignment.centerLeft,
child: Row(
children: [
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.subtitle2,
),
),
...actions,
],
),
),
);
}
/// Toggle button for use as a child of a [ToggleButtons] widget.
class ToggleButton extends StatelessWidget {
const ToggleButton({
@required this.icon,
@required this.text,
@required this.enabledTooltip,
@required this.disabledTooltip,
@required this.includeTextWidth,
@required this.selected,
});
final IconData icon;
final String text;
final String enabledTooltip;
final String disabledTooltip;
final double includeTextWidth;
final bool selected;
@override
Widget build(BuildContext context) {
return Tooltip(
message: selected ? enabledTooltip : disabledTooltip,
waitDuration: tooltipWait,
preferBelow: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: defaultSpacing),
child: MaterialIconLabel(
icon,
text,
includeTextWidth: includeTextWidth,
),
),
);
}
}
/// Button to export data.
///
/// * `includeTextWidth`: The minimum width the button can be before the text is
/// omitted.
/// * `onPressed`: The callback to be called upon pressing the button.
class ExportButton extends StatelessWidget {
const ExportButton({
Key key,
@required this.includeTextWidth,
@required this.onPressed,
}) : super(key: key);
final double includeTextWidth;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return OutlineButton(
key: key,
onPressed: onPressed,
child: MaterialIconLabel(
Icons.file_download,
'Export',
includeTextWidth: includeTextWidth,
),
);
}
}
class OutlineDecoration extends StatelessWidget {
const OutlineDecoration({Key key, this.child}) : super(key: key);
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).focusColor),
),
child: child,
);
}
}
class RoundedOutlinedBorder extends StatelessWidget {
const RoundedOutlinedBorder({Key key, this.child}) : super(key: key);
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).focusColor),
borderRadius: BorderRadius.circular(borderPadding),
),
child: child,
);
}
}
/// The golden ratio.
///
/// Makes for nice-looking rectangles.
final goldenRatio = 1 + sqrt(5) / 2;
class DisabledForProfileBuildMessage extends StatelessWidget {
const DisabledForProfileBuildMessage();
static const message =
'This screen is disabled because you are running a profile build of your '
'application.';
@override
Widget build(BuildContext context) {
return const CenteredMessage(message);
}
}
class DisabledForWebAppMessage extends StatelessWidget {
const DisabledForWebAppMessage();
static const message =
'This screen is disabled because you are connected to a web application.';
@override
Widget build(BuildContext context) {
return const CenteredMessage(message);
}
}
class DisabledForFlutterWebProfileBuildMessage extends StatelessWidget {
const DisabledForFlutterWebProfileBuildMessage();
static const message =
'This screen is disabled because you are connected to a profile build of '
'your Flutter Web application.';
@override
Widget build(BuildContext context) {
return const CenteredMessage(message);
}
}
class DisabledForNonFlutterAppMessage extends StatelessWidget {
const DisabledForNonFlutterAppMessage();
static const message =
'This screen is disabled because you are not running a Flutter '
'application.';
@override
Widget build(BuildContext context) {
return const CenteredMessage(message);
}
}
class CenteredMessage extends StatelessWidget {
const CenteredMessage(this.message);
final String message;
@override
Widget build(BuildContext context) {
return Center(
child: Text(
message,
style: Theme.of(context).textTheme.headline6,
),
);
}
}
class CircularIconButton extends StatelessWidget {
const CircularIconButton({
@required this.icon,
@required this.onPressed,
@required this.backgroundColor,
@required this.foregroundColor,
});
final IconData icon;
final VoidCallback onPressed;
final Color backgroundColor;
final Color foregroundColor;
@override
Widget build(BuildContext context) {
return RawMaterialButton(
fillColor: backgroundColor,
hoverColor: Theme.of(context).hoverColor,
elevation: 0.0,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
constraints: const BoxConstraints.tightFor(
width: 24.0,
height: 24.0,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20.0),
side: BorderSide(
width: 2.0,
color: foregroundColor,
),
),
onPressed: onPressed,
child: Icon(
icon,
size: defaultIconSize,
color: foregroundColor,
),
);
}
}
/// An extension on [ScrollController] to facilitate having the scrolling widget
/// auto scroll to the bottom on new content.
extension ScrollControllerAutoScroll on ScrollController {
// TODO(devoncarew): We lose dock-to-bottom when we receive content when we're
// off screen.
/// Return whether the view is currently scrolled to the bottom.
bool get atScrollBottom {
final pos = position;
return pos.pixels == pos.maxScrollExtent;
}
/// Scroll the content to the bottom using the app's default animation
/// duration and curve..
void autoScrollToBottom() async {
await animateTo(
position.maxScrollExtent,
duration: rapidDuration,
curve: defaultCurve,
);
// Scroll again if we've received new content in the interim.
if (hasClients) {
final pos = position;
if (pos.pixels != pos.maxScrollExtent) {
jumpTo(pos.maxScrollExtent);
}
}
}
}
/// Utility extension methods to the [Color] class.
extension ColorExtension on Color {
/// Return a slightly darker color than the current color.
Color darken([double percent = 0.05]) {
assert(0.0 <= percent && percent <= 1.0);
percent = 1.0 - percent;
final c = this;
return Color.fromARGB(
c.alpha,
(c.red * percent).round(),
(c.green * percent).round(),
(c.blue * percent).round(),
);
}
/// Return a slightly brighter color than the current color.
Color brighten([double percent = 0.05]) {
assert(0.0 <= percent && percent <= 1.0);
final c = this;
return Color.fromARGB(
c.alpha,
c.red + ((255 - c.red) * percent).round(),
c.green + ((255 - c.green) * percent).round(),
c.blue + ((255 - c.blue) * percent).round(),
);
}
}
/// Utility extension methods to the [ThemeData] class.
extension ThemeDataExtension on ThemeData {
/// Returns whether we are currently using a dark theme.
bool get isDarkTheme => brightness == Brightness.dark;
TextStyle get regularTextStyle => TextStyle(color: textTheme.bodyText2.color);
TextStyle get subtleTextStyle => TextStyle(color: unselectedWidgetColor);
TextStyle get selectedTextStyle => TextStyle(color: textSelectionColor);
}
/// Gets an alternating color to use for indexed UI elements.
Color alternatingColorForIndexWithContext(int index, BuildContext context) {
final theme = Theme.of(context);
final color = theme.canvasColor;
return _colorForIndex(color, index, isDarkTheme: theme.isDarkTheme);
}
Color alternatingColorForIndex(int index, {@required bool isDarkTheme}) {
final color = defaultBackgroundColor;
return _colorForIndex(color, index, isDarkTheme: isDarkTheme);
}
Color _colorForIndex(Color color, int index, {@required bool isDarkTheme}) {
if (index % 2 == 1) {
return color;
} else {
return isDarkTheme ? color.brighten() : color.darken();
}
}