blob: c6cfb91e223a2fd03379bf4c2cca87c468479fe7 [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 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:pedantic/pedantic.dart';
import 'package:provider/provider.dart';
import '../devtools.dart' as devtools;
import 'common_widgets.dart';
import 'connect_screen.dart';
import 'debugger/debugger_controller.dart';
import 'debugger/debugger_screen.dart';
import 'dialogs.dart';
import 'framework/framework_core.dart';
import 'globals.dart';
import 'initializer.dart';
import 'inspector/inspector_screen.dart';
import 'logging/logging_controller.dart';
import 'logging/logging_screen.dart';
import 'memory/memory_controller.dart';
import 'memory/memory_screen.dart';
import 'network/network_controller.dart';
import 'network/network_screen.dart';
import 'notifications.dart';
import 'performance/performance_controller.dart';
import 'performance/performance_screen.dart';
import 'preferences.dart';
import 'scaffold.dart';
import 'screen.dart';
import 'snapshot_screen.dart';
import 'theme.dart';
import 'timeline/timeline_controller.dart';
import 'timeline/timeline_screen.dart';
import 'ui/service_extension_widgets.dart';
import 'utils.dart';
const homeRoute = '/';
const snapshotRoute = '/snapshot';
/// Top-level configuration for the app.
@immutable
class DevToolsApp extends StatefulWidget {
const DevToolsApp(this.screens, this.preferences);
final List<DevToolsScreen> screens;
final PreferencesController preferences;
@override
State<DevToolsApp> createState() => DevToolsAppState();
static DevToolsAppState of(BuildContext context) {
return context.findAncestorStateOfType<DevToolsAppState>();
}
}
/// Initializer for the [FrameworkCore] and the app's navigation.
///
/// This manages the route generation, and marshalls URL query parameters into
/// flutter route parameters.
// TODO(https://github.com/flutter/devtools/issues/1146): Introduce tests that
// navigate the full app.
class DevToolsAppState extends State<DevToolsApp> {
List<Screen> get _screens => widget.screens.map((s) => s.screen).toList();
PreferencesController get preferences => widget.preferences;
@override
void initState() {
super.initState();
serviceManager.isolateManager.onSelectedIsolateChanged.listen((_) {
setState(() {
_clearCachedRoutes();
});
});
}
@override
void didUpdateWidget(DevToolsApp oldWidget) {
super.didUpdateWidget(oldWidget);
_clearCachedRoutes();
}
/// Generates routes, separating the path from URL query parameters.
Route _generateRoute(RouteSettings settings) {
final uri = Uri.parse(settings.name);
final path = uri.path.isEmpty ? homeRoute : uri.path;
final args = settings.arguments;
// Provide the appropriate page route.
if (routes.containsKey(path)) {
WidgetBuilder builder = (context) => routes[path](
context,
uri.queryParameters,
args,
);
assert(() {
builder = (context) => _AlternateCheckedModeBanner(
builder: (context) => routes[path](
context,
uri.queryParameters,
args,
),
);
return true;
}());
return MaterialPageRoute(settings: settings, builder: builder);
}
// Return a page not found.
return MaterialPageRoute(
settings: settings,
builder: (BuildContext context) {
return DevToolsScaffold.withChild(
child: CenteredMessage("'$uri' not found."),
);
},
);
}
/// The routes that the app exposes.
Map<String, UrlParametersBuilder> get routes {
return _routes ??= {
homeRoute: (_, params, __) {
if (params['uri']?.isNotEmpty ?? false) {
final embed = params['embed'] == 'true';
final page = params['page'];
final tabs = embed && page != null
? _visibleScreens().where((p) => p.screenId == page).toList()
: _visibleScreens();
return Initializer(
url: params['uri'],
allowConnectionScreenOnDisconnect: !embed,
builder: (_) => _providedControllers(
child: DevToolsScaffold(
embed: embed,
initialPage: page,
tabs: tabs,
actions: [
if (serviceManager.connectedApp.isFlutterAppNow) ...[
HotReloadButton(),
HotRestartButton(),
],
OpenSettingsAction(),
OpenAboutAction(),
],
),
),
);
} else {
return DevToolsScaffold.withChild(child: ConnectScreenBody());
}
},
snapshotRoute: (_, __, args) {
return DevToolsScaffold.withChild(
child: _providedControllers(
offline: true,
child: SnapshotScreenBody(args, _screens),
),
);
},
};
}
Map<String, UrlParametersBuilder> _routes;
void _clearCachedRoutes() {
_routes = null;
}
List<Screen> _visibleScreens() {
final visibleScreens = <Screen>[];
for (var screen in _screens) {
if (screen.conditionalLibrary != null) {
if (serviceManager.isServiceAvailable &&
serviceManager
.isolateManager.selectedIsolateAvailable.isCompleted &&
serviceManager.libraryUriAvailableNow(screen.conditionalLibrary)) {
visibleScreens.add(screen);
}
} else {
visibleScreens.add(screen);
}
}
return visibleScreens;
}
Widget _providedControllers({@required Widget child, bool offline = false}) {
final _providers = widget.screens
.where((s) =>
s.createController != null && (offline ? s.supportsOffline : true))
.map((s) => s.controllerProvider)
.toList();
return MultiProvider(
providers: _providers,
child: child,
);
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: widget.preferences.darkModeTheme,
builder: (context, value, _) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: themeFor(isDarkTheme: value),
builder: (context, child) => Notifications(child: child),
onGenerateRoute: _generateRoute,
);
},
);
}
}
/// DevTools screen wrapper that is responsible for creating and providing the
/// screen's controller, as well as enabling offline support.
///
/// [C] corresponds to the type of the screen's controller, which is created by
/// [createController] and provided by [controllerProvider].
class DevToolsScreen<C> {
const DevToolsScreen(
this.screen, {
@required this.createController,
this.supportsOffline = false,
});
final Screen screen;
/// Responsible for creating the controller for this screen, if non-null.
///
/// The controller will then be provided via [controllerProvider], and
/// widgets depending on this controller can access it by calling
/// `Provider<C>.of(context)`.
///
/// If null, [screen] will be responsible for creating and maintaining its own
/// controller.
final C Function() createController;
/// Whether this screen has implemented offline support.
///
/// Defaults to false.
final bool supportsOffline;
Provider<C> get controllerProvider {
assert(createController != null);
return Provider<C>(create: (_) => createController());
}
}
/// A [WidgetBuilder] that takes an additional map of URL query parameters and
/// args.
typedef UrlParametersBuilder = Widget Function(
BuildContext,
Map<String, String>,
SnapshotArguments args,
);
/// Displays the checked mode banner in the bottom end corner instead of the
/// top end corner.
///
/// This avoids issues with widgets in the appbar being hidden by the banner
/// in a web or desktop app.
class _AlternateCheckedModeBanner extends StatelessWidget {
const _AlternateCheckedModeBanner({Key key, this.builder}) : super(key: key);
final WidgetBuilder builder;
@override
Widget build(BuildContext context) {
return Banner(
message: 'DEBUG',
textDirection: TextDirection.ltr,
location: BannerLocation.topStart,
child: Builder(
builder: builder,
),
);
}
}
class OpenAboutAction extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ActionButton(
tooltip: 'About DevTools',
child: InkWell(
onTap: () async {
unawaited(showDialog(
context: context,
builder: (context) => DevToolsAboutDialog(),
));
},
child: Container(
width: DevToolsScaffold.actionWidgetSize,
height: DevToolsScaffold.actionWidgetSize,
alignment: Alignment.center,
child: const Icon(
Icons.help_outline,
size: actionsIconSize,
),
),
),
);
}
}
class OpenSettingsAction extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ActionButton(
tooltip: 'Settings',
child: InkWell(
onTap: () async {
unawaited(showDialog(
context: context,
builder: (context) => SettingsDialog(),
));
},
child: Container(
width: DevToolsScaffold.actionWidgetSize,
height: DevToolsScaffold.actionWidgetSize,
alignment: Alignment.center,
child: const Icon(
Icons.settings,
size: actionsIconSize,
),
),
),
);
}
}
class DevToolsAboutDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return DevToolsDialog(
title: dialogTitleText(theme, 'About DevTools'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_aboutDevTools(context),
const SizedBox(height: defaultSpacing),
...dialogSubHeader(theme, 'Feedback'),
Wrap(
children: [
const Text('Encountered an issue? Let us know at '),
_createFeedbackLink(context),
const Text('.')
],
),
],
),
actions: [
DialogCloseButton(),
],
);
}
Widget _aboutDevTools(BuildContext context) {
return const SelectableText('DevTools version ${devtools.version}');
}
Widget _createFeedbackLink(BuildContext context) {
const urlPath = 'github.com/flutter/devtools/issues';
final colorScheme = Theme.of(context).colorScheme;
return InkWell(
onTap: () async {
// TODO(devoncarew): Support analytics.
// ga.select(ga.devToolsMain, ga.feedback);
const reportIssuesUrl = 'https://$urlPath';
await launchUrl(reportIssuesUrl, context);
},
child: Text(urlPath, style: linkTextStyle(colorScheme)),
);
}
}
// TODO(devoncarew): Add an analytics setting.
class SettingsDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
final preferences = DevToolsApp.of(context).preferences;
return DevToolsDialog(
title: dialogTitleText(Theme.of(context), 'Settings'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () {
preferences.toggleDarkModeTheme(!preferences.darkModeTheme.value);
},
child: Row(
children: [
ValueListenableBuilder(
valueListenable: preferences.darkModeTheme,
builder: (context, value, _) {
return Checkbox(
value: value,
onChanged: (bool value) {
preferences.toggleDarkModeTheme(value);
},
);
},
),
const Text('Use a dark theme'),
],
),
),
],
),
actions: [
DialogCloseButton(),
],
);
}
}
/// Screens to initialize DevTools with.
///
/// If the screen depends on a provided controller, the provider should be
/// provided here.
///
/// Conditional screens can be added to this list, and they will automatically
/// be shown or hidden based on the [Screen.conditionalLibrary] provided.
List<DevToolsScreen> get defaultScreens => <DevToolsScreen>[
const DevToolsScreen(InspectorScreen(), createController: null),
DevToolsScreen<TimelineController>(
const TimelineScreen(),
createController: () => TimelineController(),
supportsOffline: true,
),
DevToolsScreen<MemoryController>(
const MemoryScreen(),
createController: () => MemoryController(),
),
DevToolsScreen<PerformanceController>(
const PerformanceScreen(),
createController: () => PerformanceController(),
supportsOffline: true,
),
DevToolsScreen<DebuggerController>(
const DebuggerScreen(),
createController: () => DebuggerController(),
),
DevToolsScreen<NetworkController>(
const NetworkScreen(),
createController: () => NetworkController(),
),
DevToolsScreen<LoggingController>(
const LoggingScreen(),
createController: () => LoggingController(),
),
// Uncomment to see a sample implementation of a conditional screen.
// DevToolsScreen<ExampleController>(
// const ExampleConditionalScreen(),
// createController: () => ExampleController(),
// supportsOffline: true,
// ),
];