blob: 7337f850a383d947f0bc363b967a5166c733479b [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:math' as math;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../analytics/prompt.dart';
import '../app.dart';
import '../config_specific/drag_and_drop/drag_and_drop.dart';
import '../config_specific/ide_theme/ide_theme.dart';
import '../config_specific/import_export/import_export.dart';
import '../primitives/auto_dispose_mixin.dart';
import '../screens/debugger/console.dart';
import '../screens/debugger/debugger_screen.dart';
import 'banner_messages.dart';
import 'common_widgets.dart';
import 'framework_controller.dart';
import 'globals.dart';
import 'notifications.dart';
import 'routing.dart';
import 'screen.dart';
import 'split.dart';
import 'status_line.dart';
import 'theme.dart';
import 'title.dart';
import 'utils.dart';
/// Scaffolding for a screen and navigation in the DevTools App.
///
/// This widget will host Screen widgets.
///
/// [DevToolsApp] defines the collections of [Screen]s to show in a scaffold
/// for different routes.
class DevToolsScaffold extends StatefulWidget {
const DevToolsScaffold({
Key? key,
required this.tabs,
this.page,
this.actions,
this.embed = false,
required this.ideTheme,
}) : super(key: key);
DevToolsScaffold.withChild({
Key? key,
required Widget child,
required IdeTheme ideTheme,
List<Widget>? actions,
}) : this(
key: key,
tabs: [SimpleScreen(child)],
ideTheme: ideTheme,
actions: actions,
);
/// A [Key] that indicates the scaffold is showing in narrow-width mode.
static const Key narrowWidthKey = Key('Narrow Scaffold');
/// A [Key] that indicates the scaffold is showing in full-width mode.
static const Key fullWidthKey = Key('Full-width Scaffold');
/// The size that all actions on this widget are expected to have.
static double get actionWidgetSize => scaleByFontFactor(48.0);
/// The padding around the content in the DevTools UI.
EdgeInsets get appPadding => EdgeInsets.fromLTRB(
horizontalPadding.left,
isEmbedded() ? 2.0 : defaultSpacing,
horizontalPadding.right,
isEmbedded() ? 0.0 : denseSpacing,
);
// Note: when changing this value, also update `flameChartContainerOffset`
// from flame_chart.dart.
/// Horizontal padding around the content in the DevTools UI.
static EdgeInsets get horizontalPadding =>
EdgeInsets.symmetric(horizontal: isEmbedded() ? 2.0 : 16.0);
/// All of the [Screen]s that it's possible to navigate to from this Scaffold.
final List<Screen> tabs;
/// The page being rendered.
final String? page;
/// Whether to render the embedded view (without the header).
final bool embed;
/// IDE-supplied theming.
final IdeTheme ideTheme;
/// Actions that it's possible to perform in this Scaffold.
///
/// These will generally be [RegisteredServiceExtensionButton]s.
final List<Widget>? actions;
@override
State<StatefulWidget> createState() => DevToolsScaffoldState();
}
class DevToolsScaffoldState extends State<DevToolsScaffold>
with AutoDisposeMixin, TickerProviderStateMixin {
/// A tag used for [Hero] widgets to keep the [AppBar] in the same place
/// across route transitions.
static const Object _appBarTag = 'DevTools AppBar';
/// The controller for animating between tabs.
///
/// This will be passed to both the [TabBar] and the [TabBarView] widgets to
/// coordinate their animation when the tab selection changes.
TabController? _tabController;
late Screen _currentScreen;
late ImportController _importController;
late String scaffoldTitle;
@override
void initState() {
super.initState();
addAutoDisposeListener(offlineController.offlineMode);
_setupTabController();
autoDisposeStreamSubscription(
frameworkController.onShowPageId.listen(_showPageById),
);
_initTitle();
}
@override
void didUpdateWidget(DevToolsScaffold oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.tabs.length != oldWidget.tabs.length) {
var newIndex = 0;
// Stay on the current tab if possible when the collection of tabs changes.
if (_tabController != null &&
widget.tabs.contains(oldWidget.tabs[_tabController!.index])) {
newIndex = widget.tabs.indexOf(oldWidget.tabs[_tabController!.index]);
}
// Create a new tab controller to reflect the changed tabs.
_setupTabController();
_tabController!.index = newIndex;
} else if (widget.tabs[_tabController!.index].screenId != widget.page) {
// If the page changed (eg. the route was modified by pressing back in the
// browser), animate to the new one.
final newIndex = widget.page == null
? 0 // When there's no supplied page, we show the first one.
: widget.tabs.indexWhere((t) => t.screenId == widget.page);
_tabController!.animateTo(newIndex);
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_importController = ImportController(
Notifications.of(context)!,
_pushSnapshotScreenForImport,
);
// This needs to be called at the scaffold level because we need an instance
// of Notifications above this context.
surveyService.maybeShowSurveyPrompt(context);
}
@override
void dispose() {
_tabController?.dispose();
super.dispose();
}
void _initTitle() {
scaffoldTitle = devToolsTitle.value;
addAutoDisposeListener(devToolsTitle, () {
setState(() {
scaffoldTitle = devToolsTitle.value;
});
});
}
void _setupTabController() {
_tabController?.dispose();
_tabController = TabController(length: widget.tabs.length, vsync: this);
if (widget.page != null) {
final initialIndex =
widget.tabs.indexWhere((screen) => screen.screenId == widget.page);
if (initialIndex != -1) {
_tabController!.index = initialIndex;
}
}
_currentScreen = widget.tabs[_tabController!.index];
_tabController!.addListener(() {
final screen = widget.tabs[_tabController!.index];
if (_currentScreen != screen) {
setState(() {
_currentScreen = screen;
});
// Send the page change info to the framework controller (it can then
// send it on to the devtools server, if one is connected).
frameworkController.notifyPageChange(
PageChangeEvent(screen.screenId, widget.embed),
);
// Clear error count when navigating to a screen.
serviceManager.errorBadgeManager.clearErrors(screen.screenId);
// Update routing with the change.
final routerDelegate = DevToolsRouterDelegate.of(context);
routerDelegate.navigateIfNotCurrent(screen.screenId);
}
});
// If we had no explicit page, we want to write one into the URL but
// without triggering a navigation. Since we can't nagivate during a build
// we have to wrap this in `Future.microtask`.
if (widget.page == null && _currentScreen is! SimpleScreen) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final routerDelegate = DevToolsRouterDelegate.of(context);
Router.neglect(context, () {
routerDelegate.navigateIfNotCurrent(
_currentScreen.screenId,
routerDelegate.currentConfiguration?.args,
);
});
});
}
// Broadcast the initial page.
frameworkController.notifyPageChange(
PageChangeEvent(_currentScreen.screenId, widget.embed),
);
}
/// Switch to the given page ID. This request usually comes from the server API
/// for example if the user clicks the Inspector button in the IDE and DevTools
/// is already open on the Memory page, it should transition to the Inspector page.
void _showPageById(String pageId) {
final existingTabIndex = _tabController!.index;
final newIndex =
widget.tabs.indexWhere((screen) => screen.screenId == pageId);
if (newIndex != -1 && newIndex != existingTabIndex) {
DevToolsRouterDelegate.of(context).navigateIfNotCurrent(pageId);
}
}
/// Pushes the snapshot screen for an offline import.
void _pushSnapshotScreenForImport(String screenId) {
// TODO(kenz): for 'performance' imports, load the legacy screen or the new
// screen based on the flutter version of the imported file.
final args = {'screen': screenId};
final routerDelegate = DevToolsRouterDelegate.of(context);
if (!offlineController.offlineMode.value) {
routerDelegate.navigate(snapshotPageId, args);
} else {
// If we are already in offline mode, we need to replace the existing page
// so clicking Back does not go through all of the old snapshots.
// Router.neglect will cause the router to ignore this change, so
// dragging a new export into the browser will not result in a new
// history entry.
Router.neglect(
context,
() => routerDelegate.navigate(snapshotPageId, args),
);
}
}
@override
Widget build(BuildContext context) {
// Build the screens for each tab and wrap them in the appropriate styling.
final tabBodies = [
for (var screen in widget.tabs)
Container(
// TODO(kenz): this padding creates a flash when dragging and dropping
// into the app size screen because it creates space that is outside
// of the [DragAndDropEventAbsorber] widget. Fix this.
padding: widget.appPadding,
alignment: Alignment.topLeft,
child: FocusScope(
child: AnalyticsPrompt(
child: BannerMessages(
screen: screen,
),
),
),
),
];
final content = Stack(
children: [
TabBarView(
physics: defaultTabBarViewPhysics,
controller: _tabController,
children: tabBodies,
),
if (serviceManager.connectedAppInitialized &&
!serviceManager.connectedApp!.isProfileBuildNow! &&
!offlineController.offlineMode.value &&
_currentScreen.showFloatingDebuggerControls)
Container(
alignment: Alignment.topCenter,
child: FloatingDebuggerControls(),
),
],
);
final theme = Theme.of(context);
return Provider<BannerMessagesController>(
create: (_) => BannerMessagesController(),
child: Provider<ImportController>.value(
value: _importController,
builder: (context, _) {
return DragAndDrop(
handleDrop: _importController.importData,
child: Title(
title: scaffoldTitle,
// Color is a required parameter but the color only appears to
// matter on Android and we do not care about Android.
// Using theme.primaryColor matches the default behavior of the
// title used by [WidgetsApp].
color: theme.primaryColor,
child: KeyboardShortcuts(
keyboardShortcuts: _currentScreen.buildKeyboardShortcuts(
context,
),
child: Scaffold(
appBar: widget.embed
? null
: _buildAppBar(scaffoldTitle) as PreferredSizeWidget?,
body: (serviceManager.connectedAppInitialized &&
!serviceManager.connectedApp!.isProfileBuildNow! &&
!offlineController.offlineMode.value &&
_currentScreen.showConsole(widget.embed))
? Split(
axis: Axis.vertical,
children: [
content,
Padding(
padding: DevToolsScaffold.horizontalPadding,
child: const DebuggerConsole(),
),
],
splitters: [
DebuggerConsole.buildHeader(),
],
initialFractions: const [0.8, 0.2],
)
: content,
bottomNavigationBar: StatusLine(
currentScreen: _currentScreen,
isEmbedded: widget.embed,
),
),
),
),
);
},
),
);
}
/// Builds an [AppBar] with the [TabBar] placed on the side or the bottom,
/// depending on the screen width.
Widget _buildAppBar(String title) {
Widget? flexibleSpace;
late final Size preferredSize;
TabBar tabBar;
final isNarrow =
MediaQuery.of(context).size.width <= _wideWidth(title, widget);
// Add a leading [BulletSpacer] to the actions if the screen is not narrow.
final actions = List<Widget>.from(widget.actions ?? []);
if (!isNarrow && actions.isNotEmpty && widget.tabs.length > 1) {
actions.insert(0, const BulletSpacer(useAccentColor: true));
}
final bool hasMultipleTabs = widget.tabs.length > 1;
if (hasMultipleTabs) {
tabBar = TabBar(
controller: _tabController,
isScrollable: true,
tabs: [for (var screen in widget.tabs) screen.buildTab(context)],
);
preferredSize = isNarrow
? Size.fromHeight(
defaultToolbarHeight + scaleByFontFactor(36.0) + 4.0,
)
: Size.fromHeight(defaultToolbarHeight);
final alignment = isNarrow ? Alignment.bottomLeft : Alignment.centerRight;
final rightAdjust = isNarrow ? 0.0 : BulletSpacer.width;
final rightPadding = isNarrow
? 0.0
: math.max(
0.0,
DevToolsScaffold.actionWidgetSize * (actions.length) -
rightAdjust,
);
flexibleSpace = Align(
alignment: alignment,
child: Padding(
padding: EdgeInsets.only(
top: isNarrow ? scaleByFontFactor(36.0) + 4.0 : 4.0,
right: rightPadding,
),
child: tabBar,
),
);
}
final appBar = AppBar(
// Turn off the appbar's back button.
automaticallyImplyLeading: false,
title: Text(
title,
style: Theme.of(context).devToolsTitleStyle,
),
centerTitle: false,
toolbarHeight: defaultToolbarHeight,
actions: actions,
flexibleSpace: flexibleSpace,
);
if (!hasMultipleTabs) return appBar;
return PreferredSize(
key: isNarrow
? DevToolsScaffold.narrowWidthKey
: DevToolsScaffold.fullWidthKey,
preferredSize: preferredSize,
// Place the AppBar inside of a Hero widget to keep it the same across
// route transitions.
child: Hero(
tag: _appBarTag,
child: appBar,
),
);
}
/// Returns the width of the scaffold title, tabs and default icons.
double _wideWidth(String title, DevToolsScaffold widget) {
final textTheme = Theme.of(context).textTheme;
final painter = TextPainter(
text: TextSpan(
text: title,
style: textTheme.headline6,
),
textDirection: TextDirection.ltr,
)..layout();
// Approximate size of the title. Add [defaultSpacing] to account for
// title's leading padding.
double wideWidth = painter.width + defaultSpacing;
for (var tab in widget.tabs) {
wideWidth += tab.approximateWidth(textTheme);
}
final actionsLength = widget.actions?.length ?? 0;
if (actionsLength > 0) {
wideWidth += actionsLength * DevToolsScaffold.actionWidgetSize +
BulletSpacer.width;
}
return wideWidth;
}
}
class KeyboardShortcuts extends StatefulWidget {
const KeyboardShortcuts({
required this.keyboardShortcuts,
required this.child,
});
final ShortcutsConfiguration keyboardShortcuts;
final Widget child;
@override
KeyboardShortcutsState createState() => KeyboardShortcutsState();
}
class KeyboardShortcutsState extends State<KeyboardShortcuts>
with AutoDisposeMixin {
late final FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = FocusNode(debugLabel: 'keyboard-shortcuts');
autoDisposeFocusNode(_focusNode);
}
@override
Widget build(BuildContext context) {
if (widget.keyboardShortcuts.isEmpty) {
return widget.child;
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => FocusScope.of(context).requestFocus(_focusNode),
child: FocusableActionDetector(
child: widget.child,
shortcuts: widget.keyboardShortcuts.shortcuts,
actions: widget.keyboardShortcuts.actions,
autofocus: true,
focusNode: _focusNode,
),
);
}
}
class SimpleScreen extends Screen {
const SimpleScreen(this.child)
: super(
id,
showFloatingDebuggerControls: false,
);
static const id = 'simple';
final Widget child;
@override
Widget build(BuildContext context) {
return child;
}
}