// Copyright 2014 The Flutter 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:developer' show Flow, Timeline; import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart' hide Flow; import 'app_bar.dart'; import 'back_button.dart'; import 'card.dart'; import 'constants.dart'; import 'debug.dart'; import 'dialog.dart'; import 'divider.dart'; import 'floating_action_button_location.dart'; import 'ink_decoration.dart'; import 'list_tile.dart'; import 'material.dart'; import 'material_localizations.dart'; import 'page.dart'; import 'page_transitions_theme.dart'; import 'progress_indicator.dart'; import 'scaffold.dart'; import 'scrollbar.dart'; import 'text_button.dart'; import 'text_theme.dart'; import 'theme.dart'; // Examples can assume: // BuildContext context; /// A [ListTile] that shows an about box. /// /// This widget is often added to an app's [Drawer]. When tapped it shows /// an about box dialog with [showAboutDialog]. /// /// The about box will include a button that shows licenses for software used by /// the application. The licenses shown are those returned by the /// [LicenseRegistry] API, which can be used to add more licenses to the list. /// /// If your application does not have a [Drawer], you should provide an /// affordance to call [showAboutDialog] or (at least) [showLicensePage]. /// /// {@tool dartpad} /// This sample shows two ways to open [AboutDialog]. The first one /// uses an [AboutListTile], and the second uses the [showAboutDialog] function. /// /// ** See code in examples/api/lib/material/about/about_list_tile.0.dart ** /// {@end-tool} class AboutListTile extends StatelessWidget { /// Creates a list tile for showing an about box. /// /// The arguments are all optional. The application name, if omitted, will be /// derived from the nearest [Title] widget. The version, icon, and legalese /// values default to the empty string. const AboutListTile({ super.key, this.icon, this.child, this.applicationName, this.applicationVersion, this.applicationIcon, this.applicationLegalese, this.aboutBoxChildren, this.dense, }); /// The icon to show for this drawer item. /// /// By default no icon is shown. /// /// This is not necessarily the same as the image shown in the dialog box /// itself; which is controlled by the [applicationIcon] property. final Widget? icon; /// The label to show on this drawer item. /// /// Defaults to a text widget that says "About Foo" where "Foo" is the /// application name specified by [applicationName]. final Widget? child; /// The name of the application. /// /// This string is used in the default label for this drawer item (see /// [child]) and as the caption of the [AboutDialog] that is shown. /// /// Defaults to the value of [Title.title], if a [Title] widget can be found. /// Otherwise, defaults to [Platform.resolvedExecutable]. final String? applicationName; /// The version of this build of the application. /// /// This string is shown under the application name in the [AboutDialog]. /// /// Defaults to the empty string. final String? applicationVersion; /// The icon to show next to the application name in the [AboutDialog]. /// /// By default no icon is shown. /// /// Typically this will be an [ImageIcon] widget. It should honor the /// [IconTheme]'s [IconThemeData.size]. /// /// This is not necessarily the same as the icon shown on the drawer item /// itself, which is controlled by the [icon] property. final Widget? applicationIcon; /// A string to show in small print in the [AboutDialog]. /// /// Typically this is a copyright notice. /// /// Defaults to the empty string. final String? applicationLegalese; /// Widgets to add to the [AboutDialog] after the name, version, and legalese. /// /// This could include a link to a Web site, some descriptive text, credits, /// or other information to show in the about box. /// /// Defaults to nothing. final List<Widget>? aboutBoxChildren; /// Whether this list tile is part of a vertically dense list. /// /// If this property is null, then its value is based on [ListTileThemeData.dense]. /// /// Dense list tiles default to a smaller height. final bool? dense; @override Widget build(BuildContext context) { assert(debugCheckHasMaterial(context)); assert(debugCheckHasMaterialLocalizations(context)); return ListTile( leading: icon, title: child ?? Text(MaterialLocalizations.of(context).aboutListTileTitle( applicationName ?? _defaultApplicationName(context), )), dense: dense, onTap: () { showAboutDialog( context: context, applicationName: applicationName, applicationVersion: applicationVersion, applicationIcon: applicationIcon, applicationLegalese: applicationLegalese, children: aboutBoxChildren, ); }, ); } } /// Displays an [AboutDialog], which describes the application and provides a /// button to show licenses for software used by the application. /// /// The arguments correspond to the properties on [AboutDialog]. /// /// If the application has a [Drawer], consider using [AboutListTile] instead /// of calling this directly. /// /// If you do not need an about box in your application, you should at least /// provide an affordance to call [showLicensePage]. /// /// The licenses shown on the [LicensePage] are those returned by the /// [LicenseRegistry] API, which can be used to add more licenses to the list. /// /// The [context], [useRootNavigator], [routeSettings] and [anchorPoint] /// arguments are passed to [showDialog], the documentation for which discusses /// how it is used. void showAboutDialog({ required BuildContext context, String? applicationName, String? applicationVersion, Widget? applicationIcon, String? applicationLegalese, List<Widget>? children, bool useRootNavigator = true, RouteSettings? routeSettings, Offset? anchorPoint, }) { showDialog<void>( context: context, useRootNavigator: useRootNavigator, builder: (BuildContext context) { return AboutDialog( applicationName: applicationName, applicationVersion: applicationVersion, applicationIcon: applicationIcon, applicationLegalese: applicationLegalese, children: children, ); }, routeSettings: routeSettings, anchorPoint: anchorPoint, ); } /// Displays a [LicensePage], which shows licenses for software used by the /// application. /// /// The application arguments correspond to the properties on [LicensePage]. /// /// The `context` argument is used to look up the [Navigator] for the page. /// /// The `useRootNavigator` argument is used to determine whether to push the /// page to the [Navigator] furthest from or nearest to the given `context`. It /// is `false` by default. /// /// If the application has a [Drawer], consider using [AboutListTile] instead /// of calling this directly. /// /// The [AboutDialog] shown by [showAboutDialog] includes a button that calls /// [showLicensePage]. /// /// The licenses shown on the [LicensePage] are those returned by the /// [LicenseRegistry] API, which can be used to add more licenses to the list. void showLicensePage({ required BuildContext context, String? applicationName, String? applicationVersion, Widget? applicationIcon, String? applicationLegalese, bool useRootNavigator = false, }) { Navigator.of(context, rootNavigator: useRootNavigator).push(MaterialPageRoute<void>( builder: (BuildContext context) => LicensePage( applicationName: applicationName, applicationVersion: applicationVersion, applicationIcon: applicationIcon, applicationLegalese: applicationLegalese, ), )); } /// The amount of vertical space to separate chunks of text. const double _textVerticalSeparation = 18.0; /// An about box. This is a dialog box with the application's icon, name, /// version number, and copyright, plus a button to show licenses for software /// used by the application. /// /// To show an [AboutDialog], use [showAboutDialog]. /// /// {@youtube 560 315 https://www.youtube.com/watch?v=YFCSODyFxbE} /// /// If the application has a [Drawer], the [AboutListTile] widget can make the /// process of showing an about dialog simpler. /// /// The [AboutDialog] shown by [showAboutDialog] includes a button that calls /// [showLicensePage]. /// /// The licenses shown on the [LicensePage] are those returned by the /// [LicenseRegistry] API, which can be used to add more licenses to the list. class AboutDialog extends StatelessWidget { /// Creates an about box. /// /// The arguments are all optional. The application name, if omitted, will be /// derived from the nearest [Title] widget. The version, icon, and legalese /// values default to the empty string. const AboutDialog({ super.key, this.applicationName, this.applicationVersion, this.applicationIcon, this.applicationLegalese, this.children, }); /// The name of the application. /// /// Defaults to the value of [Title.title], if a [Title] widget can be found. /// Otherwise, defaults to [Platform.resolvedExecutable]. final String? applicationName; /// The version of this build of the application. /// /// This string is shown under the application name. /// /// Defaults to the empty string. final String? applicationVersion; /// The icon to show next to the application name. /// /// By default no icon is shown. /// /// Typically this will be an [ImageIcon] widget. It should honor the /// [IconTheme]'s [IconThemeData.size]. final Widget? applicationIcon; /// A string to show in small print. /// /// Typically this is a copyright notice. /// /// Defaults to the empty string. final String? applicationLegalese; /// Widgets to add to the dialog box after the name, version, and legalese. /// /// This could include a link to a Web site, some descriptive text, credits, /// or other information to show in the about box. /// /// Defaults to nothing. final List<Widget>? children; @override Widget build(BuildContext context) { assert(debugCheckHasMaterialLocalizations(context)); final String name = applicationName ?? _defaultApplicationName(context); final String version = applicationVersion ?? _defaultApplicationVersion(context); final Widget? icon = applicationIcon ?? _defaultApplicationIcon(context); final ThemeData themeData = Theme.of(context); final MaterialLocalizations localizations = MaterialLocalizations.of(context); return AlertDialog( content: ListBody( children: <Widget>[ Row( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ if (icon != null) IconTheme(data: themeData.iconTheme, child: icon), Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), child: ListBody( children: <Widget>[ Text(name, style: themeData.textTheme.headlineSmall), Text(version, style: themeData.textTheme.bodyMedium), const SizedBox(height: _textVerticalSeparation), Text(applicationLegalese ?? '', style: themeData.textTheme.bodySmall), ], ), ), ), ], ), ...?children, ], ), actions: <Widget>[ TextButton( child: Text( themeData.useMaterial3 ? localizations.viewLicensesButtonLabel : localizations.viewLicensesButtonLabel.toUpperCase() ), onPressed: () { showLicensePage( context: context, applicationName: applicationName, applicationVersion: applicationVersion, applicationIcon: applicationIcon, applicationLegalese: applicationLegalese, ); }, ), TextButton( child: Text( themeData.useMaterial3 ? localizations.closeButtonLabel : localizations.closeButtonLabel.toUpperCase() ), onPressed: () { Navigator.pop(context); }, ), ], scrollable: true, ); } } /// A page that shows licenses for software used by the application. /// /// To show a [LicensePage], use [showLicensePage]. /// /// The [AboutDialog] shown by [showAboutDialog] and [AboutListTile] includes /// a button that calls [showLicensePage]. /// /// The licenses shown on the [LicensePage] are those returned by the /// [LicenseRegistry] API, which can be used to add more licenses to the list. class LicensePage extends StatefulWidget { /// Creates a page that shows licenses for software used by the application. /// /// The arguments are all optional. The application name, if omitted, will be /// derived from the nearest [Title] widget. The version and legalese values /// default to the empty string. /// /// The licenses shown on the [LicensePage] are those returned by the /// [LicenseRegistry] API, which can be used to add more licenses to the list. const LicensePage({ super.key, this.applicationName, this.applicationVersion, this.applicationIcon, this.applicationLegalese, }); /// The name of the application. /// /// Defaults to the value of [Title.title], if a [Title] widget can be found. /// Otherwise, defaults to [Platform.resolvedExecutable]. final String? applicationName; /// The version of this build of the application. /// /// This string is shown under the application name. /// /// Defaults to the empty string. final String? applicationVersion; /// The icon to show below the application name. /// /// By default no icon is shown. /// /// Typically this will be an [ImageIcon] widget. It should honor the /// [IconTheme]'s [IconThemeData.size]. final Widget? applicationIcon; /// A string to show in small print. /// /// Typically this is a copyright notice. /// /// Defaults to the empty string. final String? applicationLegalese; @override State<LicensePage> createState() => _LicensePageState(); } class _LicensePageState extends State<LicensePage> { final ValueNotifier<int?> selectedId = ValueNotifier<int?>(null); @override void dispose() { selectedId.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return _MasterDetailFlow( detailPageFABlessGutterWidth: _getGutterSize(context), title: Text(MaterialLocalizations.of(context).licensesPageTitle), detailPageBuilder: _packageLicensePage, masterViewBuilder: _packagesView, ); } Widget _packageLicensePage(BuildContext _, Object? args, ScrollController? scrollController) { assert(args is _DetailArguments); final _DetailArguments detailArguments = args! as _DetailArguments; return _PackageLicensePage( packageName: detailArguments.packageName, licenseEntries: detailArguments.licenseEntries, scrollController: scrollController, ); } Widget _packagesView(final BuildContext _, final bool isLateral) { final Widget about = _AboutProgram( name: widget.applicationName ?? _defaultApplicationName(context), icon: widget.applicationIcon ?? _defaultApplicationIcon(context), version: widget.applicationVersion ?? _defaultApplicationVersion(context), legalese: widget.applicationLegalese, ); return _PackagesView( about: about, isLateral: isLateral, selectedId: selectedId, ); } } class _AboutProgram extends StatelessWidget { const _AboutProgram({ required this.name, required this.version, this.icon, this.legalese, }); final String name; final String version; final Widget? icon; final String? legalese; @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.symmetric( horizontal: _getGutterSize(context), vertical: 24.0, ), child: Column( children: <Widget>[ Text( name, style: Theme.of(context).textTheme.headlineSmall, textAlign: TextAlign.center, ), if (icon != null) IconTheme(data: Theme.of(context).iconTheme, child: icon!), if (version != '') Padding( padding: const EdgeInsets.only(bottom: _textVerticalSeparation), child: Text( version, style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), ), if (legalese != null && legalese != '') Text( legalese!, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center, ), const SizedBox(height: _textVerticalSeparation), Text( 'Powered by Flutter', style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), ], ), ); } } class _PackagesView extends StatefulWidget { const _PackagesView({ required this.about, required this.isLateral, required this.selectedId, }); final Widget about; final bool isLateral; final ValueNotifier<int?> selectedId; @override _PackagesViewState createState() => _PackagesViewState(); } class _PackagesViewState extends State<_PackagesView> { final Future<_LicenseData> licenses = LicenseRegistry.licenses .fold<_LicenseData>( _LicenseData(), (_LicenseData prev, LicenseEntry license) => prev..addLicense(license), ) .then((_LicenseData licenseData) => licenseData..sortPackages()); @override Widget build(BuildContext context) { return FutureBuilder<_LicenseData>( future: licenses, builder: (BuildContext context, AsyncSnapshot<_LicenseData> snapshot) { return LayoutBuilder( key: ValueKey<ConnectionState>(snapshot.connectionState), builder: (BuildContext context, BoxConstraints constraints) { switch (snapshot.connectionState) { case ConnectionState.done: if (snapshot.hasError) { assert(() { FlutterError.reportError(FlutterErrorDetails( exception: snapshot.error!, stack: snapshot.stackTrace, context: ErrorDescription('while decoding the license file'), )); return true; }()); return Center(child: Text(snapshot.error.toString())); } _initDefaultDetailPage(snapshot.data!, context); return ValueListenableBuilder<int?>( valueListenable: widget.selectedId, builder: (BuildContext context, int? selectedId, Widget? _) { return Center( child: Material( color: Theme.of(context).cardColor, elevation: 4.0, child: Container( constraints: BoxConstraints.loose(const Size.fromWidth(600.0)), child: _packagesList(context, selectedId, snapshot.data!, widget.isLateral), ), ), ); }, ); case ConnectionState.none: case ConnectionState.active: case ConnectionState.waiting: return Material( color: Theme.of(context).cardColor, child: Column( children: <Widget>[ widget.about, const Center(child: CircularProgressIndicator()), ], ), ); } }, ); }, ); } void _initDefaultDetailPage(_LicenseData data, BuildContext context) { if (data.packages.isEmpty) { return; } final String packageName = data.packages[widget.selectedId.value ?? 0]; final List<int> bindings = data.packageLicenseBindings[packageName]!; _MasterDetailFlow.of(context).setInitialDetailPage( _DetailArguments( packageName, bindings.map((int i) => data.licenses[i]).toList(growable: false), ), ); } Widget _packagesList( final BuildContext context, final int? selectedId, final _LicenseData data, final bool drawSelection, ) { return ListView.builder( itemCount: data.packages.length + 1, itemBuilder: (BuildContext context, int index) { if (index == 0) { return widget.about; } final int packageIndex = index - 1; final String packageName = data.packages[packageIndex]; final List<int> bindings = data.packageLicenseBindings[packageName]!; return _PackageListTile( packageName: packageName, index: packageIndex, isSelected: drawSelection && packageIndex == (selectedId ?? 0), numberLicenses: bindings.length, onTap: () { widget.selectedId.value = packageIndex; _MasterDetailFlow.of(context).openDetailPage(_DetailArguments( packageName, bindings.map((int i) => data.licenses[i]).toList(growable: false), )); }, ); }, ); } } class _PackageListTile extends StatelessWidget { const _PackageListTile({ required this.packageName, this.index, required this.isSelected, required this.numberLicenses, this.onTap, }); final String packageName; final int? index; final bool isSelected; final int numberLicenses; final GestureTapCallback? onTap; @override Widget build(BuildContext context) { return Ink( color: isSelected ? Theme.of(context).highlightColor : Theme.of(context).cardColor, child: ListTile( title: Text(packageName), subtitle: Text(MaterialLocalizations.of(context).licensesPackageDetailText(numberLicenses)), selected: isSelected, onTap: onTap, ), ); } } /// This is a collection of licenses and the packages to which they apply. /// [packageLicenseBindings] records the m+:n+ relationship between the license /// and packages as a map of package names to license indexes. class _LicenseData { final List<LicenseEntry> licenses = <LicenseEntry>[]; final Map<String, List<int>> packageLicenseBindings = <String, List<int>>{}; final List<String> packages = <String>[]; // Special treatment for the first package since it should be the package // for delivered application. String? firstPackage; void addLicense(LicenseEntry entry) { // Before the license can be added, we must first record the packages to // which it belongs. for (final String package in entry.packages) { _addPackage(package); // Bind this license to the package using the next index value. This // creates a contract that this license must be inserted at this same // index value. packageLicenseBindings[package]!.add(licenses.length); } licenses.add(entry); // Completion of the contract above. } /// Add a package and initialize package license binding. This is a no-op if /// the package has been seen before. void _addPackage(String package) { if (!packageLicenseBindings.containsKey(package)) { packageLicenseBindings[package] = <int>[]; firstPackage ??= package; packages.add(package); } } /// Sort the packages using some comparison method, or by the default manner, /// which is to put the application package first, followed by every other /// package in case-insensitive alphabetical order. void sortPackages([int Function(String a, String b)? compare]) { packages.sort(compare ?? (String a, String b) { // Based on how LicenseRegistry currently behaves, the first package // returned is the end user application license. This should be // presented first in the list. So here we make sure that first package // remains at the front regardless of alphabetical sorting. if (a == firstPackage) { return -1; } if (b == firstPackage) { return 1; } return a.toLowerCase().compareTo(b.toLowerCase()); }); } } @immutable class _DetailArguments { const _DetailArguments(this.packageName, this.licenseEntries); final String packageName; final List<LicenseEntry> licenseEntries; @override bool operator ==(final Object other) { if (other is _DetailArguments) { return other.packageName == packageName; } return other == this; } @override int get hashCode => Object.hash(packageName, Object.hashAll(licenseEntries)); } class _PackageLicensePage extends StatefulWidget { const _PackageLicensePage({ required this.packageName, required this.licenseEntries, required this.scrollController, }); final String packageName; final List<LicenseEntry> licenseEntries; final ScrollController? scrollController; @override _PackageLicensePageState createState() => _PackageLicensePageState(); } class _PackageLicensePageState extends State<_PackageLicensePage> { @override void initState() { super.initState(); _initLicenses(); } final List<Widget> _licenses = <Widget>[]; bool _loaded = false; Future<void> _initLicenses() async { int debugFlowId = -1; assert(() { final Flow flow = Flow.begin(); Timeline.timeSync('_initLicenses()', () { }, flow: flow); debugFlowId = flow.id; return true; }()); for (final LicenseEntry license in widget.licenseEntries) { if (!mounted) { return; } assert(() { Timeline.timeSync('_initLicenses()', () { }, flow: Flow.step(debugFlowId)); return true; }()); final List<LicenseParagraph> paragraphs = await SchedulerBinding.instance.scheduleTask<List<LicenseParagraph>>( license.paragraphs.toList, Priority.animation, debugLabel: 'License', ); if (!mounted) { return; } setState(() { _licenses.add(const Padding( padding: EdgeInsets.all(18.0), child: Divider(), )); for (final LicenseParagraph paragraph in paragraphs) { if (paragraph.indent == LicenseParagraph.centeredIndent) { _licenses.add(Padding( padding: const EdgeInsets.only(top: 16.0), child: Text( paragraph.text, style: const TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), )); } else { assert(paragraph.indent >= 0); _licenses.add(Padding( padding: EdgeInsetsDirectional.only(top: 8.0, start: 16.0 * paragraph.indent), child: Text(paragraph.text), )); } } }); } setState(() { _loaded = true; }); assert(() { Timeline.timeSync('Build scheduled', () { }, flow: Flow.end(debugFlowId)); return true; }()); } @override Widget build(BuildContext context) { assert(debugCheckHasMaterialLocalizations(context)); final MaterialLocalizations localizations = MaterialLocalizations.of(context); final ThemeData theme = Theme.of(context); final String title = widget.packageName; final String subtitle = localizations.licensesPackageDetailText(widget.licenseEntries.length); final double pad = _getGutterSize(context); final EdgeInsets padding = EdgeInsets.only(left: pad, right: pad, bottom: pad); final List<Widget> listWidgets = <Widget>[ ..._licenses, if (!_loaded) const Padding( padding: EdgeInsets.symmetric(vertical: 24.0), child: Center( child: CircularProgressIndicator(), ), ), ]; final Widget page; if (widget.scrollController == null) { page = Scaffold( appBar: AppBar( title: _PackageLicensePageTitle( title, subtitle, theme.appBarTheme.textTheme ?? theme.primaryTextTheme, ), ), body: Center( child: Material( color: theme.cardColor, elevation: 4.0, child: Container( constraints: BoxConstraints.loose(const Size.fromWidth(600.0)), child: Localizations.override( locale: const Locale('en', 'US'), context: context, child: ScrollConfiguration( // A Scrollbar is built-in below. behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), child: Scrollbar( child: ListView(padding: padding, children: listWidgets), ), ), ), ), ), ), ); } else { page = CustomScrollView( controller: widget.scrollController, slivers: <Widget>[ SliverAppBar( automaticallyImplyLeading: false, pinned: true, backgroundColor: theme.cardColor, title: _PackageLicensePageTitle(title, subtitle, theme.textTheme), ), SliverPadding( padding: padding, sliver: SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) => Localizations.override( locale: const Locale('en', 'US'), context: context, child: listWidgets[index], ), childCount: listWidgets.length, ), ), ), ], ); } return DefaultTextStyle( style: theme.textTheme.bodySmall!, child: page, ); } } class _PackageLicensePageTitle extends StatelessWidget { const _PackageLicensePageTitle( this.title, this.subtitle, this.theme, ); final String title; final String subtitle; final TextTheme theme; @override Widget build(BuildContext context) { final Color? color = Theme.of(context).appBarTheme.foregroundColor; return Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ Text(title, style: theme.titleLarge?.copyWith(color: color)), Text(subtitle, style: theme.titleSmall?.copyWith(color: color)), ], ); } } String _defaultApplicationName(BuildContext context) { // This doesn't handle the case of the application's title dynamically // changing. In theory, we should make Title expose the current application // title using an InheritedWidget, and so forth. However, in practice, if // someone really wants their application title to change dynamically, they // can provide an explicit applicationName to the widgets defined in this // file, instead of relying on the default. final Title? ancestorTitle = context.findAncestorWidgetOfExactType<Title>(); return ancestorTitle?.title ?? Platform.resolvedExecutable.split(Platform.pathSeparator).last; } String _defaultApplicationVersion(BuildContext context) { // TODO(ianh): Get this from the embedder somehow. return ''; } Widget? _defaultApplicationIcon(BuildContext context) { // TODO(ianh): Get this from the embedder somehow. return null; } const int _materialGutterThreshold = 720; const double _wideGutterSize = 24.0; const double _narrowGutterSize = 12.0; double _getGutterSize(BuildContext context) => MediaQuery.sizeOf(context).width >= _materialGutterThreshold ? _wideGutterSize : _narrowGutterSize; /// Signature for the builder callback used by [_MasterDetailFlow]. typedef _MasterViewBuilder = Widget Function(BuildContext context, bool isLateralUI); /// Signature for the builder callback used by [_MasterDetailFlow.detailPageBuilder]. /// /// scrollController is provided when the page destination is the draggable /// sheet in the lateral UI. Otherwise, it is null. typedef _DetailPageBuilder = Widget Function(BuildContext context, Object? arguments, ScrollController? scrollController); /// Signature for the builder callback used by [_MasterDetailFlow.actionBuilder]. /// /// Builds the actions that go in the app bars constructed for the master and /// lateral UI pages. actionLevel indicates the intended destination of the /// return actions. typedef _ActionBuilder = List<Widget> Function(BuildContext context, _ActionLevel actionLevel); /// Describes which type of app bar the actions are intended for. enum _ActionLevel { /// Indicates the top app bar in the lateral UI. top, /// Indicates the master view app bar in the lateral UI. view, } /// Describes which layout will be used by [_MasterDetailFlow]. enum _LayoutMode { /// Use a nested or lateral layout depending on available screen width. auto, /// Always use a lateral layout. lateral, /// Always use a nested layout. nested, } const String _navMaster = 'master'; const String _navDetail = 'detail'; enum _Focus { master, detail } /// A Master Detail Flow widget. Depending on screen width it builds either a /// lateral or nested navigation flow between a master view and a detail page. /// bloc pattern. /// /// If focus is on detail view, then switching to nested navigation will /// populate the navigation history with the master page and the detail page on /// top. Otherwise the focus is on the master view and just the master page /// is shown. class _MasterDetailFlow extends StatefulWidget { /// Creates a master detail navigation flow which is either nested or /// lateral depending on screen width. const _MasterDetailFlow({ required this.detailPageBuilder, required this.masterViewBuilder, this.automaticallyImplyLeading = true, // ignore: unused_element this.detailPageFABlessGutterWidth, this.displayMode = _LayoutMode.auto, // ignore: unused_element this.title, }); /// Builder for the master view for lateral navigation. /// /// If [masterPageBuilder] is not supplied the master page required for nested navigation, also /// builds the master view inside a [Scaffold] with an [AppBar]. final _MasterViewBuilder masterViewBuilder; /// Builder for the detail page. /// /// If scrollController == null, the page is intended for nested navigation. The lateral detail /// page is inside a [DraggableScrollableSheet] and should have a scrollable element that uses /// the [ScrollController] provided. In fact, it is strongly recommended the entire lateral /// page is scrollable. final _DetailPageBuilder detailPageBuilder; /// Override the width of the gutter when there is no floating action button. final double? detailPageFABlessGutterWidth; /// The title for the lateral UI [AppBar]. /// /// See [AppBar.title]. final Widget? title; /// Override the framework from determining whether to show a leading widget or not. /// /// See [AppBar.automaticallyImplyLeading]. final bool automaticallyImplyLeading; /// Forces display mode and style. final _LayoutMode displayMode; @override _MasterDetailFlowState createState() => _MasterDetailFlowState(); // The master detail flow proxy from the closest instance of this class that encloses the given // context. // // Typical usage is as follows: // // ```dart // _MasterDetailFlow.of(context).openDetailPage(arguments); // ``` static _MasterDetailFlowProxy of(BuildContext context) { _PageOpener? pageOpener = context.findAncestorStateOfType<_MasterDetailScaffoldState>(); pageOpener ??= context.findAncestorStateOfType<_MasterDetailFlowState>(); assert(() { if (pageOpener == null) { throw FlutterError( 'Master Detail operation requested with a context that does not include a Master Detail ' 'Flow.\nThe context used to open a detail page from the Master Detail Flow must be ' 'that of a widget that is a descendant of a Master Detail Flow widget.', ); } return true; }()); return _MasterDetailFlowProxy._(pageOpener!); } } /// Interface for interacting with the [_MasterDetailFlow]. class _MasterDetailFlowProxy implements _PageOpener { _MasterDetailFlowProxy._(this._pageOpener); final _PageOpener _pageOpener; /// Open detail page with arguments. @override void openDetailPage(Object arguments) => _pageOpener.openDetailPage(arguments); /// Set the initial page to be open for the lateral layout. This can be set at any time, but /// will have no effect after any calls to openDetailPage. @override void setInitialDetailPage(Object arguments) => _pageOpener.setInitialDetailPage(arguments); } abstract class _PageOpener { void openDetailPage(Object arguments); void setInitialDetailPage(Object arguments); } const int _materialWideDisplayThreshold = 840; class _MasterDetailFlowState extends State<_MasterDetailFlow> implements _PageOpener { /// Tracks whether focus is on the detail or master views. Determines behavior when switching /// from lateral to nested navigation. _Focus focus = _Focus.master; /// Cache of arguments passed when opening a detail page. Used when rebuilding. Object? _cachedDetailArguments; /// Record of the layout that was built. _LayoutMode? _builtLayout; /// Key to access navigator in the nested layout. final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>(); @override void openDetailPage(Object arguments) { _cachedDetailArguments = arguments; if (_builtLayout == _LayoutMode.nested) { _navigatorKey.currentState!.pushNamed(_navDetail, arguments: arguments); } else { focus = _Focus.detail; } } @override void setInitialDetailPage(Object arguments) { _cachedDetailArguments = arguments; } @override Widget build(BuildContext context) { switch (widget.displayMode) { case _LayoutMode.nested: return _nestedUI(context); case _LayoutMode.lateral: return _lateralUI(context); case _LayoutMode.auto: return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { final double availableWidth = constraints.maxWidth; if (availableWidth >= _materialWideDisplayThreshold) { return _lateralUI(context); } else { return _nestedUI(context); } }); } } Widget _nestedUI(BuildContext context) { _builtLayout = _LayoutMode.nested; final MaterialPageRoute<void> masterPageRoute = _masterPageRoute(context); return WillPopScope( // Push pop check into nested navigator. onWillPop: () async => !(await _navigatorKey.currentState!.maybePop()), child: Navigator( key: _navigatorKey, initialRoute: 'initial', onGenerateInitialRoutes: (NavigatorState navigator, String initialRoute) { switch (focus) { case _Focus.master: return <Route<void>>[masterPageRoute]; case _Focus.detail: return <Route<void>>[ masterPageRoute, _detailPageRoute(_cachedDetailArguments), ]; } }, onGenerateRoute: (RouteSettings settings) { switch (settings.name) { case _navMaster: // Matching state to navigation event. focus = _Focus.master; return masterPageRoute; case _navDetail: // Matching state to navigation event. focus = _Focus.detail; // Cache detail page settings. _cachedDetailArguments = settings.arguments; return _detailPageRoute(_cachedDetailArguments); default: throw Exception('Unknown route ${settings.name}'); } }, ), ); } MaterialPageRoute<void> _masterPageRoute(BuildContext context) { return MaterialPageRoute<dynamic>( builder: (BuildContext c) => BlockSemantics( child: _MasterPage( leading: widget.automaticallyImplyLeading && Navigator.of(context).canPop() ? BackButton(onPressed: () => Navigator.of(context).pop()) : null, title: widget.title, automaticallyImplyLeading: widget.automaticallyImplyLeading, masterViewBuilder: widget.masterViewBuilder, ), ), ); } MaterialPageRoute<void> _detailPageRoute(Object? arguments) { return MaterialPageRoute<dynamic>(builder: (BuildContext context) { return WillPopScope( onWillPop: () async { // No need for setState() as rebuild happens on navigation pop. focus = _Focus.master; Navigator.of(context).pop(); return false; }, child: BlockSemantics(child: widget.detailPageBuilder(context, arguments, null)), ); }); } Widget _lateralUI(BuildContext context) { _builtLayout = _LayoutMode.lateral; return _MasterDetailScaffold( actionBuilder: (_, __) => const<Widget>[], automaticallyImplyLeading: widget.automaticallyImplyLeading, detailPageBuilder: (BuildContext context, Object? args, ScrollController? scrollController) => widget.detailPageBuilder(context, args ?? _cachedDetailArguments, scrollController), detailPageFABlessGutterWidth: widget.detailPageFABlessGutterWidth, initialArguments: _cachedDetailArguments, masterViewBuilder: (BuildContext context, bool isLateral) => widget.masterViewBuilder(context, isLateral), title: widget.title, ); } } class _MasterPage extends StatelessWidget { const _MasterPage({ this.leading, this.title, this.masterViewBuilder, required this.automaticallyImplyLeading, }); final _MasterViewBuilder? masterViewBuilder; final Widget? title; final Widget? leading; final bool automaticallyImplyLeading; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: title, leading: leading, actions: const <Widget>[], automaticallyImplyLeading: automaticallyImplyLeading, ), body: masterViewBuilder!(context, false), ); } } const double _kCardElevation = 4.0; const double _kMasterViewWidth = 320.0; const double _kDetailPageFABlessGutterWidth = 40.0; const double _kDetailPageFABGutterWidth = 84.0; class _MasterDetailScaffold extends StatefulWidget { const _MasterDetailScaffold({ required this.detailPageBuilder, required this.masterViewBuilder, this.actionBuilder, this.initialArguments, this.title, required this.automaticallyImplyLeading, this.detailPageFABlessGutterWidth, }); final _MasterViewBuilder masterViewBuilder; /// Builder for the detail page. /// /// The detail page is inside a [DraggableScrollableSheet] and should have a scrollable element /// that uses the [ScrollController] provided. In fact, it is strongly recommended the entire /// lateral page is scrollable. final _DetailPageBuilder detailPageBuilder; final _ActionBuilder? actionBuilder; final Object? initialArguments; final Widget? title; final bool automaticallyImplyLeading; final double? detailPageFABlessGutterWidth; @override _MasterDetailScaffoldState createState() => _MasterDetailScaffoldState(); } class _MasterDetailScaffoldState extends State<_MasterDetailScaffold> implements _PageOpener { late FloatingActionButtonLocation floatingActionButtonLocation; late double detailPageFABGutterWidth; late double detailPageFABlessGutterWidth; late double masterViewWidth; final ValueNotifier<Object?> _detailArguments = ValueNotifier<Object?>(null); @override void initState() { super.initState(); detailPageFABlessGutterWidth = widget.detailPageFABlessGutterWidth ?? _kDetailPageFABlessGutterWidth; detailPageFABGutterWidth = _kDetailPageFABGutterWidth; masterViewWidth = _kMasterViewWidth; floatingActionButtonLocation = FloatingActionButtonLocation.endTop; } @override void dispose() { _detailArguments.dispose(); super.dispose(); } @override void openDetailPage(Object arguments) { SchedulerBinding.instance.addPostFrameCallback((_) => _detailArguments.value = arguments); _MasterDetailFlow.of(context).openDetailPage(arguments); } @override void setInitialDetailPage(Object arguments) { SchedulerBinding.instance.addPostFrameCallback((_) => _detailArguments.value = arguments); _MasterDetailFlow.of(context).setInitialDetailPage(arguments); } @override Widget build(BuildContext context) { return Stack( children: <Widget>[ Scaffold( floatingActionButtonLocation: floatingActionButtonLocation, appBar: AppBar( title: widget.title, actions: widget.actionBuilder!(context, _ActionLevel.top), automaticallyImplyLeading: widget.automaticallyImplyLeading, bottom: PreferredSize( preferredSize: const Size.fromHeight(kToolbarHeight), child: Row( children: <Widget>[ ConstrainedBox( constraints: BoxConstraints.tightFor(width: masterViewWidth), child: IconTheme( data: Theme.of(context).primaryIconTheme, child: Container( alignment: AlignmentDirectional.centerEnd, padding: const EdgeInsets.all(8), child: OverflowBar( spacing: 8, overflowAlignment: OverflowBarAlignment.end, children: widget.actionBuilder!(context, _ActionLevel.view), ), ), ), ), ], ), ), ), body: _masterPanel(context), ), // Detail view stacked above main scaffold and master view. SafeArea( child: Padding( padding: EdgeInsetsDirectional.only( start: masterViewWidth - _kCardElevation, end: detailPageFABlessGutterWidth, ), child: ValueListenableBuilder<Object?>( valueListenable: _detailArguments, builder: (BuildContext context, Object? value, Widget? child) { return AnimatedSwitcher( transitionBuilder: (Widget child, Animation<double> animation) => const FadeUpwardsPageTransitionsBuilder().buildTransitions<void>( null, null, animation, null, child, ), duration: const Duration(milliseconds: 500), child: Container( key: ValueKey<Object?>(value ?? widget.initialArguments), constraints: const BoxConstraints.expand(), child: _DetailView( builder: widget.detailPageBuilder, arguments: value ?? widget.initialArguments, ), ), ); }, ), ), ), ], ); } ConstrainedBox _masterPanel(BuildContext context, {bool needsScaffold = false}) { return ConstrainedBox( constraints: BoxConstraints(maxWidth: masterViewWidth), child: needsScaffold ? Scaffold( appBar: AppBar( title: widget.title, actions: widget.actionBuilder!(context, _ActionLevel.top), automaticallyImplyLeading: widget.automaticallyImplyLeading, ), body: widget.masterViewBuilder(context, true), ) : widget.masterViewBuilder(context, true), ); } } class _DetailView extends StatelessWidget { const _DetailView({ required _DetailPageBuilder builder, Object? arguments, }) : _builder = builder, _arguments = arguments; final _DetailPageBuilder _builder; final Object? _arguments; @override Widget build(BuildContext context) { if (_arguments == null) { return const SizedBox.shrink(); } final double screenHeight = MediaQuery.sizeOf(context).height; final double minHeight = (screenHeight - kToolbarHeight) / screenHeight; return DraggableScrollableSheet( initialChildSize: minHeight, minChildSize: minHeight, expand: false, builder: (BuildContext context, ScrollController controller) { return MouseRegion( // TODO(TonicArtos): Remove MouseRegion workaround for pointer hover events passing through DraggableScrollableSheet once https://github.com/flutter/flutter/issues/59741 is resolved. child: Card( color: Theme.of(context).cardColor, elevation: _kCardElevation, clipBehavior: Clip.antiAlias, margin: const EdgeInsets.fromLTRB(_kCardElevation, 0.0, _kCardElevation, 0.0), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(3.0)), ), child: _builder( context, _arguments, controller, ), ), ); }, ); } }