diff --git a/packages/devtools_app/lib/devtools_app.dart b/packages/devtools_app/lib/devtools_app.dart index 37b64cd9618..f860b8af602 100644 --- a/packages/devtools_app/lib/devtools_app.dart +++ b/packages/devtools_app/lib/devtools_app.dart @@ -90,6 +90,7 @@ export 'src/shared/primitives/utils.dart'; export 'src/shared/routing.dart'; export 'src/shared/screen.dart'; export 'src/shared/scripts/script_manager.dart'; +export 'src/shared/side_panel.dart'; export 'src/shared/split.dart'; export 'src/shared/theme.dart'; export 'src/shared/ui/filter.dart'; diff --git a/packages/devtools_app/lib/src/app.dart b/packages/devtools_app/lib/src/app.dart index 8d7f064eaaf..448663f8097 100644 --- a/packages/devtools_app/lib/src/app.dart +++ b/packages/devtools_app/lib/src/app.dart @@ -336,6 +336,7 @@ class DevToolsAppState extends State with AutoDisposeMixin { ], child: NotificationsView( child: ReleaseNotesViewer( + controller: releaseNotesController, child: child, ), ), diff --git a/packages/devtools_app/lib/src/framework/about_dialog.dart b/packages/devtools_app/lib/src/framework/about_dialog.dart index 40063daa7ff..46a6967bd90 100644 --- a/packages/devtools_app/lib/src/framework/about_dialog.dart +++ b/packages/devtools_app/lib/src/framework/about_dialog.dart @@ -38,8 +38,7 @@ class DevToolsAboutDialog extends StatelessWidget { 'release notes', style: theme.linkTextStyle, ), - onTap: () => - releaseNotesController.toggleReleaseNotesVisible(true), + onTap: () => releaseNotesController.toggleVisibility(true), ), ], ), diff --git a/packages/devtools_app/lib/src/framework/release_notes/release_notes.dart b/packages/devtools_app/lib/src/framework/release_notes/release_notes.dart index 6ac0d5d21a0..4c20ea70899 100644 --- a/packages/devtools_app/lib/src/framework/release_notes/release_notes.dart +++ b/packages/devtools_app/lib/src/framework/release_notes/release_notes.dart @@ -3,182 +3,34 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:math'; import 'package:devtools_shared/devtools_shared.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; -import 'package:provider/provider.dart'; import '../../../devtools.dart' as devtools; -import '../../shared/common_widgets.dart'; -import '../../shared/config_specific/launch_url/launch_url.dart'; import '../../shared/config_specific/server/server.dart' as server; -import '../../shared/primitives/auto_dispose.dart'; -import '../../shared/theme.dart'; +import '../../shared/side_panel.dart'; final _log = Logger('release_notes'); const debugTestReleaseNotes = false; +const releaseNotesKey = Key('release_notes'); -class ReleaseNotesViewer extends StatefulWidget { +class ReleaseNotesViewer extends SidePanelViewer { const ReleaseNotesViewer({ - Key? key, - required this.child, - }) : super(key: key); - - final Widget? child; - - @override - State createState() => _ReleaseNotesViewerState(); -} - -class _ReleaseNotesViewerState extends State - with AutoDisposeMixin, SingleTickerProviderStateMixin { - static const maxViewerWidth = 600.0; - - /// Animation controller for animating the opening and closing of the viewer. - late AnimationController visibilityController; - - /// A curved animation that matches [visibilityController]. - late Animation visibilityAnimation; - - String? markdownData; - - late bool isVisible; - - late ReleaseNotesController releaseNotesController; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - releaseNotesController = Provider.of(context); - - isVisible = releaseNotesController.releaseNotesVisible.value; - markdownData = releaseNotesController.releaseNotesMarkdown.value; - - visibilityController = longAnimationController(this); - visibilityAnimation = - Tween(begin: 1.0, end: 0).animate(visibilityController); - - addAutoDisposeListener(releaseNotesController.releaseNotesVisible, () { - setState(() { - isVisible = releaseNotesController.releaseNotesVisible.value; - if (isVisible) { - visibilityController.forward(); - } else { - visibilityController.reverse(); - } - }); - }); - - markdownData = releaseNotesController.releaseNotesMarkdown.value; - addAutoDisposeListener(releaseNotesController.releaseNotesMarkdown, () { - setState(() { - markdownData = releaseNotesController.releaseNotesMarkdown.value; - }); - }); - } - - @override - Widget build(BuildContext context) { - final child = widget.child; - return Material( - child: LayoutBuilder( - builder: (context, constraints) { - final widthForSmallScreen = constraints.maxWidth - 2 * densePadding; - final width = min( - _ReleaseNotesViewerState.maxViewerWidth, - widthForSmallScreen, - ); - return Stack( - children: [ - if (child != null) child, - ReleaseNotes( - releaseNotesController: releaseNotesController, - visibilityAnimation: visibilityAnimation, - markdownData: markdownData, - width: width, - ), - ], - ); - }, - ), - ); - } -} - -class ReleaseNotes extends AnimatedWidget { - const ReleaseNotes({ - Key? key, - required this.releaseNotesController, - required Animation visibilityAnimation, - required this.markdownData, - required this.width, - }) : super(key: key, listenable: visibilityAnimation); - - final ReleaseNotesController releaseNotesController; - - final String? markdownData; - - final double width; - - @override - Widget build(BuildContext context) { - final animation = listenable as Animation; - final theme = Theme.of(context); - final displacement = width * animation.value; - final right = densePadding - displacement; - return Positioned( - top: densePadding, - bottom: densePadding, - right: right, - width: width, - child: Card( - elevation: defaultElevation, - color: theme.scaffoldBackgroundColor, - clipBehavior: Clip.hardEdge, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(defaultBorderRadius), - side: BorderSide( - color: theme.focusColor, - ), - ), - child: Column( - children: [ - AreaPaneHeader( - title: const Text( - 'What\'s new in DevTools?', - ), - includeTopBorder: false, - actions: [ - IconButton( - padding: const EdgeInsets.all(0.0), - onPressed: () => - releaseNotesController.toggleReleaseNotesVisible(false), - icon: const Icon(Icons.close), - ), - ], - ), - markdownData == null - ? const Text('Stay tuned for updates.') - : Expanded( - child: Markdown( - data: markdownData!, - onTapLink: (_, href, __) => unawaited(launchUrl(href!)), - ), - ), - ], - ), - ), - ); - } + required super.controller, + Widget? child, + }) : super( + key: releaseNotesKey, + title: 'What\'s new in DevTools?', + textIfMarkdownDataEmpty: 'Stay tuned for updates.', + child: child, + ); } -class ReleaseNotesController { +class ReleaseNotesController extends SidePanelController { ReleaseNotesController() { _init(); } @@ -189,14 +41,6 @@ class ReleaseNotesController { ? 'https://flutter-website-dt-staging.web.app' : 'https://docs.flutter.dev'; - ValueListenable get releaseNotesMarkdown => _releaseNotesMarkdown; - - final _releaseNotesMarkdown = ValueNotifier(null); - - ValueListenable get releaseNotesVisible => _releaseNotesVisible; - - final _releaseNotesVisible = ValueNotifier(false); - void _init() { if (debugTestReleaseNotes || server.isDevToolsServerAvailable) { _maybeFetchReleaseNotes(); @@ -224,8 +68,8 @@ class ReleaseNotesController { } catch (e) { // Fail gracefully if we cannot find release notes for the current // version of DevTools. - _releaseNotesMarkdown.value = null; - toggleReleaseNotesVisible(false); + markdownText = null; + toggleVisibility(false); _log.warning( 'Warning: could not find release notes for DevTools version ' '$parsedCurrentVersionStr. $e', @@ -255,8 +99,8 @@ class ReleaseNotesController { _flutterDocsSite, ); - _releaseNotesMarkdown.value = releaseNotesMarkdown; - toggleReleaseNotesVisible(true); + markdownText = releaseNotesMarkdown; + toggleVisibility(true); unawaited( server.setLastShownReleaseNotesVersion(currentVersionString), ); @@ -271,10 +115,6 @@ class ReleaseNotesController { } } - void toggleReleaseNotesVisible(bool visible) { - _releaseNotesVisible.value = visible; - } - String _releaseNotesUrl(String currentVersion) { return '$_flutterDocsSite/development/tools/devtools/release-notes/' 'release-notes-$currentVersion-src.md'; diff --git a/packages/devtools_app/lib/src/shared/side_panel.dart b/packages/devtools_app/lib/src/shared/side_panel.dart new file mode 100644 index 00000000000..bdbd0b553d3 --- /dev/null +++ b/packages/devtools_app/lib/src/shared/side_panel.dart @@ -0,0 +1,212 @@ +// Copyright 2021 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'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; + +import 'common_widgets.dart'; +import 'config_specific/launch_url/launch_url.dart'; +import 'primitives/auto_dispose.dart'; +import 'primitives/utils.dart'; +import 'theme.dart'; + +class SidePanelViewer extends StatefulWidget { + const SidePanelViewer({ + Key? key, + required this.controller, + this.title, + this.textIfMarkdownDataEmpty, + this.child, + }) : super(key: key); + + final SidePanelController controller; + final String? title; + final String? textIfMarkdownDataEmpty; + final Widget? child; + + @override + SidePanelViewerState createState() => SidePanelViewerState(); +} + +class SidePanelViewerState extends State + with AutoDisposeMixin, SingleTickerProviderStateMixin { + static const maxViewerWidth = 600.0; + + /// Animation controller for animating the opening and closing of the viewer. + late AnimationController visibilityController; + + /// A curved animation that matches [visibilityController]. + late Animation visibilityAnimation; + + String? markdownData; + + late bool isVisible; + + @override + void initState() { + super.initState(); + + visibilityController = longAnimationController(this); + visibilityAnimation = + Tween(begin: 1.0, end: 0).animate(visibilityController); + + _initListeners(); + } + + @override + void didUpdateWidget(SidePanelViewer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller.isVisible.value != isVisible || + oldWidget.controller.markdown.value != markdownData) { + cancelListeners(); + _initListeners(); + } + } + + void _initListeners() { + isVisible = widget.controller.isVisible.value; + + addAutoDisposeListener(widget.controller.isVisible, () { + setState(() { + isVisible = widget.controller.isVisible.value; + if (isVisible) { + visibilityController.forward(); + } else { + visibilityController.reverse(); + } + }); + }); + + markdownData = widget.controller.markdown.value; + addAutoDisposeListener(widget.controller.markdown, () { + setState(() { + markdownData = widget.controller.markdown.value; + }); + }); + } + + @override + Widget build(BuildContext context) { + final child = widget.child; + return Material( + child: LayoutBuilder( + builder: (context, constraints) { + final widthForSmallScreen = constraints.maxWidth - 2 * densePadding; + final width = min( + SidePanelViewerState.maxViewerWidth, + widthForSmallScreen, + ); + return Stack( + children: [ + if (child != null) child, + SidePanel( + sidePanelController: widget.controller, + visibilityAnimation: visibilityAnimation, + title: widget.title, + markdownData: markdownData, + textIfMarkdownDataEmpty: widget.textIfMarkdownDataEmpty, + width: width, + ), + ], + ); + }, + ), + ); + } + + @override + void dispose() { + visibilityController.dispose(); + super.dispose(); + } +} + +class SidePanel extends AnimatedWidget { + const SidePanel({ + Key? key, + required this.sidePanelController, + required Animation visibilityAnimation, + this.title, + this.markdownData, + this.textIfMarkdownDataEmpty, + required this.width, + }) : super(key: key, listenable: visibilityAnimation); + + final SidePanelController sidePanelController; + + final String? title; + final String? markdownData; + final String? textIfMarkdownDataEmpty; + final double width; + + @override + Widget build(BuildContext context) { + final animation = listenable as Animation; + final theme = Theme.of(context); + final displacement = width * animation.value; + final right = densePadding - displacement; + return Positioned( + top: densePadding, + bottom: densePadding, + right: right, + width: width, + child: Card( + elevation: defaultElevation, + color: theme.scaffoldBackgroundColor, + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(defaultBorderRadius), + side: BorderSide( + color: theme.focusColor, + ), + ), + child: Column( + children: [ + AreaPaneHeader( + title: Text(title ?? ''), + includeTopBorder: false, + actions: [ + IconButton( + padding: const EdgeInsets.all(0.0), + onPressed: () => sidePanelController.toggleVisibility(false), + icon: const Icon(Icons.close), + ), + ], + ), + markdownData.isNullOrEmpty + ? Text(textIfMarkdownDataEmpty ?? '') + : Expanded( + child: Markdown( + data: markdownData!, + onTapLink: (text, url, title) async => + await launchUrl(url!), + ), + ), + ], + ), + ), + ); + } +} + +class SidePanelController { + ValueListenable get markdown => _markdown; + + final _markdown = ValueNotifier(null); + + ValueListenable get isVisible => _isVisible; + + final _isVisible = ValueNotifier(false); + + void toggleVisibility(bool visible) { + _isVisible.value = visible; + } + + set markdownText(String? markdownText) { + _markdown.value = markdownText; + } +} diff --git a/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart b/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart index d87b96bd6d0..5e816fc4fdf 100644 --- a/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart +++ b/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart @@ -37,10 +37,10 @@ Future pumpAndConnectDevTools( // If the release notes viewer is open, close it. final releaseNotesView = - tester.widget(find.byType(ReleaseNotes)); - if (releaseNotesView.releaseNotesController.releaseNotesVisible.value) { + tester.widget(find.byType(ReleaseNotesViewer)); + if (releaseNotesView.controller.isVisible.value) { final closeReleaseNotesButton = find.descendant( - of: find.byType(ReleaseNotes), + of: find.byType(ReleaseNotesViewer), matching: find.byType(IconButton), ); expect(closeReleaseNotesButton, findsOneWidget);