// 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 'package:dual_screen/dual_screen.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:url_launcher/url_launcher_string.dart'; import '../codeviewer/code_displayer.dart'; import '../codeviewer/code_style.dart'; import '../constants.dart'; import '../data/demos.dart'; import '../data/gallery_options.dart'; import '../feature_discovery/feature_discovery.dart'; import '../gallery_localizations.dart'; import '../layout/adaptive.dart'; import '../themes/gallery_theme_data.dart'; import '../themes/material_demo_theme_data.dart'; import 'splash.dart'; enum _DemoState { normal, options, info, code, fullscreen, } class DemoPage extends StatefulWidget { const DemoPage({ super.key, required this.slug, }); static const String baseRoute = '/demo'; final String? slug; @override State createState() => _DemoPageState(); } class _DemoPageState extends State { late Map slugToDemoMap; @override void didChangeDependencies() { super.didChangeDependencies(); // To make sure that we do not rebuild the map for every update to the demo // page, we save it in a variable. The cost of running `slugToDemo` is // still only close to constant, as it's just iterating over all of the // demos. slugToDemoMap = Demos.asSlugToDemoMap(context); } @override Widget build(BuildContext context) { if (widget.slug == null || !slugToDemoMap.containsKey(widget.slug)) { // Return to root if invalid slug. Navigator.of(context).pop(); } return ScaffoldMessenger( child: GalleryDemoPage( restorationId: widget.slug!, demo: slugToDemoMap[widget.slug]!, )); } } class GalleryDemoPage extends StatefulWidget { const GalleryDemoPage({ super.key, required this.restorationId, required this.demo, }); final String restorationId; final GalleryDemo demo; @override State createState() => _GalleryDemoPageState(); } class _GalleryDemoPageState extends State with RestorationMixin, TickerProviderStateMixin { final RestorableInt _demoStateIndex = RestorableInt(_DemoState.normal.index); final RestorableInt _configIndex = RestorableInt(0); bool? _isDesktop; late AnimationController _codeBackgroundColorController; @override String get restorationId => widget.restorationId; @override void restoreState(RestorationBucket? oldBucket, bool initialRestore) { registerForRestoration(_demoStateIndex, 'demo_state'); registerForRestoration(_configIndex, 'configuration_index'); } GalleryDemoConfiguration get _currentConfig { return widget.demo.configurations[_configIndex.value]; } bool get _hasOptions => widget.demo.configurations.length > 1; @override void initState() { super.initState(); _codeBackgroundColorController = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); } @override void dispose() { _demoStateIndex.dispose(); _configIndex.dispose(); _codeBackgroundColorController.dispose(); super.dispose(); } @override void didChangeDependencies() { super.didChangeDependencies(); _isDesktop ??= isDisplayDesktop(context); } /// Sets state and updates the background color for code. void setStateAndUpdate(VoidCallback callback) { setState(() { callback(); if (_demoStateIndex.value == _DemoState.code.index) { _codeBackgroundColorController.forward(); } else { _codeBackgroundColorController.reverse(); } }); } void _handleTap(_DemoState newState) { final int newStateIndex = newState.index; // Do not allow normal state for desktop. if (_demoStateIndex.value == newStateIndex && isDisplayDesktop(context)) { if (_demoStateIndex.value == _DemoState.fullscreen.index) { setStateAndUpdate(() { _demoStateIndex.value = _hasOptions ? _DemoState.options.index : _DemoState.info.index; }); } return; } setStateAndUpdate(() { _demoStateIndex.value = _demoStateIndex.value == newStateIndex ? _DemoState.normal.index : newStateIndex; }); } Future _showDocumentation(BuildContext context) async { final String url = _currentConfig.documentationUrl; if (await canLaunchUrlString(url)) { await launchUrlString(url); } else if (context.mounted) { await showDialog( context: context, builder: (BuildContext context) { return SimpleDialog( title: Text(GalleryLocalizations.of(context)!.demoInvalidURL), children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text(url), ), ], ); }, ); } } void _resolveState(BuildContext context) { final bool isDesktop = isDisplayDesktop(context); final bool isFoldable = isDisplayFoldable(context); if (_DemoState.values[_demoStateIndex.value] == _DemoState.fullscreen && !isDesktop) { // Do not allow fullscreen state for mobile. _demoStateIndex.value = _DemoState.normal.index; } else if (_DemoState.values[_demoStateIndex.value] == _DemoState.normal && (isDesktop || isFoldable)) { // Do not allow normal state for desktop. _demoStateIndex.value = _hasOptions ? _DemoState.options.index : _DemoState.info.index; } else if (isDesktop != _isDesktop) { _isDesktop = isDesktop; // When going from desktop to mobile, return to normal state. if (!isDesktop) { _demoStateIndex.value = _DemoState.normal.index; } } } @override Widget build(BuildContext context) { final bool isFoldable = isDisplayFoldable(context); final bool isDesktop = isDisplayDesktop(context); _resolveState(context); final ColorScheme colorScheme = Theme.of(context).colorScheme; final Color iconColor = colorScheme.onSurface; final Color selectedIconColor = colorScheme.primary; final double appBarPadding = isDesktop ? 20.0 : 0.0; final _DemoState currentDemoState = _DemoState.values[_demoStateIndex.value]; final GalleryLocalizations localizations = GalleryLocalizations.of(context)!; final GalleryOptions options = GalleryOptions.of(context); final AppBar appBar = AppBar( systemOverlayStyle: options.resolvedSystemUiOverlayStyle(), backgroundColor: Colors.transparent, leading: Padding( padding: EdgeInsetsDirectional.only(start: appBarPadding), child: IconButton( key: const ValueKey('Back'), icon: const BackButtonIcon(), tooltip: MaterialLocalizations.of(context).backButtonTooltip, onPressed: () { Navigator.maybePop(context); }, ), ), actions: [ if (_hasOptions) IconButton( icon: FeatureDiscovery( title: localizations.demoOptionsFeatureTitle, description: localizations.demoOptionsFeatureDescription, showOverlay: !isDisplayDesktop(context) && !options.isTestMode, color: colorScheme.primary, onTap: () => _handleTap(_DemoState.options), child: Icon( Icons.tune, color: currentDemoState == _DemoState.options ? selectedIconColor : iconColor, ), ), tooltip: localizations.demoOptionsTooltip, onPressed: () => _handleTap(_DemoState.options), ), IconButton( icon: const Icon(Icons.info), tooltip: localizations.demoInfoTooltip, color: currentDemoState == _DemoState.info ? selectedIconColor : iconColor, onPressed: () => _handleTap(_DemoState.info), ), IconButton( icon: const Icon(Icons.code), tooltip: localizations.demoCodeTooltip, color: currentDemoState == _DemoState.code ? selectedIconColor : iconColor, onPressed: () => _handleTap(_DemoState.code), ), IconButton( icon: const Icon(Icons.library_books), tooltip: localizations.demoDocumentationTooltip, color: iconColor, onPressed: () => _showDocumentation(context), ), if (isDesktop) IconButton( icon: const Icon(Icons.fullscreen), tooltip: localizations.demoFullscreenTooltip, color: currentDemoState == _DemoState.fullscreen ? selectedIconColor : iconColor, onPressed: () => _handleTap(_DemoState.fullscreen), ), SizedBox(width: appBarPadding), ], ); final MediaQueryData mediaQuery = MediaQuery.of(context); final double bottomSafeArea = mediaQuery.padding.bottom; final double contentHeight = mediaQuery.size.height - mediaQuery.padding.top - mediaQuery.padding.bottom - appBar.preferredSize.height; final double maxSectionHeight = isDesktop ? contentHeight : contentHeight - 64; final double horizontalPadding = isDesktop ? mediaQuery.size.width * 0.12 : 0.0; const double maxSectionWidth = 420.0; Widget section; switch (currentDemoState) { case _DemoState.options: section = _DemoSectionOptions( maxHeight: maxSectionHeight, maxWidth: maxSectionWidth, configurations: widget.demo.configurations, configIndex: _configIndex.value, onConfigChanged: (int index) { setStateAndUpdate(() { _configIndex.value = index; if (!isDesktop) { _demoStateIndex.value = _DemoState.normal.index; } }); }, ); case _DemoState.info: section = _DemoSectionInfo( maxHeight: maxSectionHeight, maxWidth: maxSectionWidth, title: _currentConfig.title, description: _currentConfig.description, ); case _DemoState.code: final TextStyle codeTheme = GoogleFonts.robotoMono( fontSize: 12 * options.textScaleFactor(context), ); section = CodeStyle( baseStyle: codeTheme.copyWith(color: const Color(0xFFFAFBFB)), numberStyle: codeTheme.copyWith(color: const Color(0xFFBD93F9)), commentStyle: codeTheme.copyWith(color: const Color(0xFF808080)), keywordStyle: codeTheme.copyWith(color: const Color(0xFF1CDEC9)), stringStyle: codeTheme.copyWith(color: const Color(0xFFFFA65C)), punctuationStyle: codeTheme.copyWith(color: const Color(0xFF8BE9FD)), classStyle: codeTheme.copyWith(color: const Color(0xFFD65BAD)), constantStyle: codeTheme.copyWith(color: const Color(0xFFFF8383)), child: _DemoSectionCode( maxHeight: maxSectionHeight, codeWidget: CodeDisplayPage( _currentConfig.code, ), ), ); case _DemoState.normal: case _DemoState.fullscreen: section = Container(); } Widget body; Widget demoContent = ScaffoldMessenger( child: DemoWrapper( height: contentHeight, buildRoute: _currentConfig.buildRoute, ), ); if (isDesktop) { final bool isFullScreen = currentDemoState == _DemoState.fullscreen; final Widget sectionAndDemo = Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isFullScreen) Expanded(child: section), SizedBox(width: !isFullScreen ? 48.0 : 0), Expanded(child: demoContent), ], ); body = SafeArea( child: Padding( padding: const EdgeInsets.only(top: 56), child: sectionAndDemo, ), ); } else if (isFoldable) { body = Padding( padding: const EdgeInsets.only(top: 12.0), child: TwoPane( startPane: demoContent, endPane: section, ), ); } else { section = AnimatedSize( duration: const Duration(milliseconds: 200), alignment: Alignment.topCenter, curve: Curves.easeIn, child: section, ); final bool isDemoNormal = currentDemoState == _DemoState.normal; // Add a tap gesture to collapse the currently opened section. demoContent = Semantics( label: '${GalleryLocalizations.of(context)!.demo}, ${widget.demo.title}', child: MouseRegion( cursor: isDemoNormal ? MouseCursor.defer : SystemMouseCursors.click, child: GestureDetector( onTap: isDemoNormal ? null : () { setStateAndUpdate(() { _demoStateIndex.value = _DemoState.normal.index; }); }, child: Semantics( excludeSemantics: !isDemoNormal, child: demoContent, ), ), ), ); body = SafeArea( bottom: false, child: ListView( // Use a non-scrollable ListView to enable animation of shifting the // demo offscreen. physics: const NeverScrollableScrollPhysics(), children: [ section, demoContent, // Fake the safe area to ensure the animation looks correct. SizedBox(height: bottomSafeArea), ], ), ); } Widget page; if (isDesktop || isFoldable) { page = AnimatedBuilder( animation: _codeBackgroundColorController, builder: (BuildContext context, Widget? child) { final Brightness themeBrightness = switch (GalleryOptions.of(context).themeMode) { ThemeMode.system => MediaQuery.of(context).platformBrightness, ThemeMode.light => Brightness.light, ThemeMode.dark => Brightness.dark, }; Widget contents = Container( padding: EdgeInsets.symmetric(horizontal: horizontalPadding), child: ApplyTextOptions( child: Scaffold( appBar: appBar, body: body, backgroundColor: Colors.transparent, ), ), ); if (themeBrightness == Brightness.light) { // If it is currently in light mode, add a // dark background for code. final Widget codeBackground = SafeArea( child: Container( padding: const EdgeInsets.only(top: 56), child: Container( color: ColorTween( begin: Colors.transparent, end: GalleryThemeData.darkThemeData.canvasColor, ).animate(_codeBackgroundColorController).value, ), ), ); contents = Stack( children: [ codeBackground, contents, ], ); } return ColoredBox( color: colorScheme.background, child: contents, ); }); } else { page = ColoredBox( color: colorScheme.background, child: ApplyTextOptions( child: Scaffold( appBar: appBar, body: body, resizeToAvoidBottomInset: false, ), ), ); } // Add the splash page functionality for desktop. if (isDesktop) { page = MediaQuery.removePadding( removeTop: true, context: context, child: SplashPage( child: page, ), ); } return FeatureDiscoveryController(page); } } class _DemoSectionOptions extends StatelessWidget { const _DemoSectionOptions({ required this.maxHeight, required this.maxWidth, required this.configurations, required this.configIndex, required this.onConfigChanged, }); final double maxHeight; final double maxWidth; final List configurations; final int configIndex; final ValueChanged onConfigChanged; @override Widget build(BuildContext context) { final TextTheme textTheme = Theme.of(context).textTheme; final ColorScheme colorScheme = Theme.of(context).colorScheme; return Align( alignment: AlignmentDirectional.topStart, child: Container( constraints: BoxConstraints(maxHeight: maxHeight, maxWidth: maxWidth), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsetsDirectional.only( start: 24, top: 12, end: 24, ), child: Text( GalleryLocalizations.of(context)!.demoOptionsTooltip, style: textTheme.headlineMedium!.apply( color: colorScheme.onSurface, fontSizeDelta: isDisplayDesktop(context) ? desktopDisplay1FontDelta : 0, ), ), ), Divider( thickness: 1, height: 16, color: colorScheme.onSurface, ), Flexible( child: ListView( shrinkWrap: true, children: [ for (final GalleryDemoConfiguration configuration in configurations) _DemoSectionOptionsItem( title: configuration.title, isSelected: configuration == configurations[configIndex], onTap: () { onConfigChanged(configurations.indexOf(configuration)); }, ), ], ), ), const SizedBox(height: 12), ], ), ), ); } } class _DemoSectionOptionsItem extends StatelessWidget { const _DemoSectionOptionsItem({ required this.title, required this.isSelected, this.onTap, }); final String title; final bool isSelected; final GestureTapCallback? onTap; @override Widget build(BuildContext context) { final ColorScheme colorScheme = Theme.of(context).colorScheme; return Material( color: isSelected ? colorScheme.surface : null, child: InkWell( onTap: onTap, child: Container( constraints: const BoxConstraints(minWidth: double.infinity), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), child: Text( title, style: Theme.of(context).textTheme.bodyMedium!.apply( color: isSelected ? colorScheme.primary : colorScheme.onSurface, ), ), ), ), ); } } class _DemoSectionInfo extends StatelessWidget { const _DemoSectionInfo({ required this.maxHeight, required this.maxWidth, required this.title, required this.description, }); final double maxHeight; final double maxWidth; final String title; final String description; @override Widget build(BuildContext context) { final TextTheme textTheme = Theme.of(context).textTheme; final ColorScheme colorScheme = Theme.of(context).colorScheme; return Align( alignment: AlignmentDirectional.topStart, child: Container( padding: const EdgeInsetsDirectional.only( start: 24, top: 12, end: 24, bottom: 32, ), constraints: BoxConstraints(maxHeight: maxHeight, maxWidth: maxWidth), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ SelectableText( title, style: textTheme.headlineMedium!.apply( color: colorScheme.onSurface, fontSizeDelta: isDisplayDesktop(context) ? desktopDisplay1FontDelta : 0, ), ), const SizedBox(height: 12), SelectableText( description, style: textTheme.bodyMedium!.apply( color: colorScheme.onSurface, ), ), ], ), ), ), ); } } class DemoWrapper extends StatelessWidget { const DemoWrapper({ super.key, required this.height, required this.buildRoute, }); final double height; final WidgetBuilder buildRoute; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), height: height, child: ClipRRect( clipBehavior: Clip.antiAliasWithSaveLayer, borderRadius: const BorderRadius.vertical( top: Radius.circular(10.0), bottom: Radius.circular(2.0), ), child: Theme( data: MaterialDemoThemeData.themeData.copyWith( platform: GalleryOptions.of(context).platform, ), child: CupertinoTheme( data: const CupertinoThemeData() .copyWith(brightness: Brightness.light), child: ApplyTextOptions( child: Builder(builder: buildRoute), ), ), ), ), ); } } class _DemoSectionCode extends StatelessWidget { const _DemoSectionCode({ this.maxHeight, this.codeWidget, }); final double? maxHeight; final Widget? codeWidget; @override Widget build(BuildContext context) { final bool isDesktop = isDisplayDesktop(context); return Theme( data: GalleryThemeData.darkThemeData, child: Padding( padding: const EdgeInsets.only(bottom: 16), child: Container( color: isDesktop ? null : GalleryThemeData.darkThemeData.canvasColor, padding: const EdgeInsets.symmetric(horizontal: 16), height: maxHeight, child: codeWidget, ), ), ); } } class CodeDisplayPage extends StatelessWidget { const CodeDisplayPage(this.code, {super.key}); final CodeDisplayer code; @override Widget build(BuildContext context) { final bool isDesktop = isDisplayDesktop(context); final TextSpan richTextCode = code(context); final String plainTextCode = richTextCode.toPlainText(); void showSnackBarOnCopySuccess(dynamic result) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( GalleryLocalizations.of(context)! .demoCodeViewerCopiedToClipboardMessage, ), ), ); } void showSnackBarOnCopyFailure(Object exception) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( GalleryLocalizations.of(context)! .demoCodeViewerFailedToCopyToClipboardMessage(exception), ), ), ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: isDesktop ? const EdgeInsets.only(bottom: 8) : const EdgeInsets.symmetric(vertical: 8), child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Colors.white.withOpacity(0.15), padding: const EdgeInsets.symmetric(horizontal: 8), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(4)), ), ), onPressed: () async { await Clipboard.setData(ClipboardData(text: plainTextCode)) .then(showSnackBarOnCopySuccess) .catchError(showSnackBarOnCopyFailure); }, child: Text( GalleryLocalizations.of(context)!.demoCodeViewerCopyAll, style: Theme.of(context).textTheme.labelLarge!.copyWith( color: Colors.white, fontWeight: FontWeight.w500, ), ), ), ), Expanded( child: SingleChildScrollView( child: Container( padding: const EdgeInsets.symmetric(vertical: 8), child: SelectableText.rich( richTextCode, textDirection: TextDirection.ltr, ), ), ), ), ], ); } }