1
0
mirror of https://github.com/flutter/samples.git synced 2025-11-10 14:58:34 +00:00

[Gallery] Fix directory structure (#312)

This commit is contained in:
Pierre-Louis
2020-02-05 20:11:54 +01:00
committed by GitHub
parent 082592e9a9
commit cee267cf88
762 changed files with 12 additions and 12 deletions

View File

@@ -0,0 +1,134 @@
// Copyright 2019 The Flutter team. 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/gestures.dart';
import 'package:flutter/material.dart';
import 'package:package_info/package_info.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:gallery/l10n/gallery_localizations.dart';
void showAboutDialog({
@required BuildContext context,
}) {
assert(context != null);
showDialog<void>(
context: context,
builder: (context) {
return _AboutDialog();
},
);
}
Future<String> getVersionNumber() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
return packageInfo.version;
}
class _AboutDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final bodyTextStyle = textTheme.body2.apply(color: colorScheme.onPrimary);
final name = 'Flutter Gallery'; // Don't need to localize.
final legalese = '© 2019 The Flutter team'; // Don't need to localize.
final samplesRepo =
GalleryLocalizations.of(context).aboutFlutterSamplesRepo;
final seeSource =
GalleryLocalizations.of(context).aboutDialogDescription(samplesRepo);
final samplesRepoIndex = seeSource.indexOf(samplesRepo);
final samplesRepoIndexEnd = samplesRepoIndex + samplesRepo.length;
final seeSourceFirst = seeSource.substring(0, samplesRepoIndex);
final seeSourceSecond = seeSource.substring(samplesRepoIndexEnd);
return AlertDialog(
backgroundColor: colorScheme.background,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
content: Container(
constraints: BoxConstraints(maxWidth: 400),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
FutureBuilder(
future: getVersionNumber(),
builder: (context, snapshot) => Text(
snapshot.hasData ? '$name ${snapshot.data}' : '$name',
style: textTheme.display1.apply(color: colorScheme.onPrimary),
),
),
SizedBox(height: 24),
RichText(
text: TextSpan(
children: [
TextSpan(
style: bodyTextStyle,
text: seeSourceFirst,
),
TextSpan(
style: bodyTextStyle.copyWith(
color: colorScheme.primary,
),
text: samplesRepo,
recognizer: TapGestureRecognizer()
..onTap = () async {
final url = 'https://github.com/flutter/samples/';
if (await canLaunch(url)) {
await launch(
url,
forceSafariVC: false,
);
}
},
),
TextSpan(
style: bodyTextStyle,
text: seeSourceSecond,
),
],
),
),
SizedBox(height: 18),
Text(
legalese,
style: bodyTextStyle,
),
],
),
),
actions: [
FlatButton(
textColor: colorScheme.primary,
child: Text(
MaterialLocalizations.of(context).viewLicensesButtonLabel,
),
onPressed: () {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (context) => Theme(
data: Theme.of(context).copyWith(
textTheme:
Typography(platform: Theme.of(context).platform).black,
scaffoldBackgroundColor: Colors.white,
),
child: LicensePage(
applicationName: name,
applicationLegalese: legalese,
),
),
));
},
),
FlatButton(
textColor: colorScheme.primary,
child: Text(MaterialLocalizations.of(context).closeButtonLabel),
onPressed: () {
Navigator.pop(context);
},
),
],
);
}
}

View File

@@ -0,0 +1,378 @@
// Copyright 2019 The Flutter team. 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:flare_dart/math/mat2d.dart';
import 'package:flare_flutter/flare.dart';
import 'package:flare_flutter/flare_actor.dart';
import 'package:flare_flutter/flare_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:gallery/constants.dart';
import 'package:gallery/data/gallery_options.dart';
import 'package:gallery/l10n/gallery_localizations.dart';
import 'package:gallery/layout/adaptive.dart';
import 'package:gallery/pages/home.dart';
import 'package:gallery/pages/settings.dart';
class AnimatedBackdrop extends StatefulWidget {
@override
_AnimatedBackdropState createState() => _AnimatedBackdropState();
}
class _AnimatedBackdropState extends State<AnimatedBackdrop>
with SingleTickerProviderStateMixin {
AnimationController backdropController;
ValueNotifier<bool> isSettingsOpenNotifier;
Animation<double> openSettingsAnimation;
Animation<double> staggerSettingsItemsAnimation;
@override
void initState() {
super.initState();
backdropController = AnimationController(
duration: Duration(milliseconds: 200),
vsync: this,
)..addListener(() {
setState(() {
// The state that has changed here is the animation.
});
});
isSettingsOpenNotifier = ValueNotifier(false);
openSettingsAnimation = CurvedAnimation(
parent: backdropController,
curve: Interval(
0.0,
0.4,
curve: Curves.ease,
),
);
staggerSettingsItemsAnimation = CurvedAnimation(
parent: backdropController,
curve: Interval(
0.5,
1.0,
curve: Curves.easeIn,
),
);
}
@override
void dispose() {
backdropController.dispose();
isSettingsOpenNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Backdrop(
controller: backdropController,
isSettingsOpenNotifier: isSettingsOpenNotifier,
openSettingsAnimation: openSettingsAnimation,
frontLayer: SettingsPage(
openSettingsAnimation: openSettingsAnimation,
staggerSettingsItemsAnimation: staggerSettingsItemsAnimation,
isSettingsOpenNotifier: isSettingsOpenNotifier,
),
backLayer: HomePage(),
);
}
}
class Backdrop extends StatefulWidget {
final Widget frontLayer;
final Widget backLayer;
final AnimationController controller;
final Animation<double> openSettingsAnimation;
final ValueNotifier<bool> isSettingsOpenNotifier;
Backdrop({
Key key,
@required this.frontLayer,
@required this.backLayer,
@required this.controller,
@required this.openSettingsAnimation,
@required this.isSettingsOpenNotifier,
}) : assert(frontLayer != null),
assert(backLayer != null),
assert(controller != null),
assert(isSettingsOpenNotifier != null),
assert(openSettingsAnimation != null),
super(key: key);
@override
_BackdropState createState() => _BackdropState();
}
class _BackdropState extends State<Backdrop>
with SingleTickerProviderStateMixin, FlareController {
FlareAnimationLayer _animationLayer;
FlutterActorArtboard _artboard;
double settingsButtonWidth = 64;
double settingsButtonHeightDesktop = 56;
double settingsButtonHeightMobile = 40;
FocusNode frontLayerFocusNode;
FocusNode backLayerFocusNode;
@override
void initState() {
super.initState();
frontLayerFocusNode = FocusNode();
backLayerFocusNode = FocusNode();
}
@override
void dispose() {
frontLayerFocusNode.dispose();
backLayerFocusNode.dispose();
super.dispose();
}
@override
void initialize(FlutterActorArtboard artboard) {
_artboard = artboard;
initAnimationLayer();
}
@override
void setViewTransform(Mat2D viewTransform) {
// This is a necessary override for the [FlareController] mixin.
}
@override
bool advance(FlutterActorArtboard artboard, double elapsed) {
if (_animationLayer != null) {
FlareAnimationLayer layer = _animationLayer;
layer.time = widget.controller.value * layer.duration;
layer.animation.apply(layer.time, _artboard, 1);
if (layer.isDone || layer.time == 0) {
_animationLayer = null;
}
}
return _animationLayer != null;
}
void initAnimationLayer() {
if (_artboard != null) {
final animationName = "Animations";
ActorAnimation animation = _artboard.getAnimation(animationName);
_animationLayer = FlareAnimationLayer()
..name = animationName
..animation = animation;
}
}
void toggleSettings() {
// Animate the settings panel to open or close.
widget.controller
.fling(velocity: widget.isSettingsOpenNotifier.value ? -1 : 1);
setState(() {
widget.isSettingsOpenNotifier.value =
!widget.isSettingsOpenNotifier.value;
});
// Animate the settings icon.
initAnimationLayer();
isActive.value = true;
}
Animation<RelativeRect> _getPanelAnimation(BoxConstraints constraints) {
final double height = constraints.biggest.height;
final double top = height - galleryHeaderHeight;
final double bottom = -galleryHeaderHeight;
return RelativeRectTween(
begin: RelativeRect.fromLTRB(0, 0, 0, 0),
end: RelativeRect.fromLTRB(0, top, 0, bottom),
).animate(CurvedAnimation(
parent: widget.openSettingsAnimation,
curve: Curves.linear,
));
}
Widget _galleryHeader() {
return ExcludeSemantics(
excluding: widget.isSettingsOpenNotifier.value,
child: Semantics(
sortKey: OrdinalSortKey(
GalleryOptions.of(context).textDirection() == TextDirection.ltr
? 1.0
: 2.0,
name: 'header',
),
label: GalleryLocalizations.of(context).homeHeaderGallery,
child: Container(),
),
);
}
Widget _buildStack(BuildContext context, BoxConstraints constraints) {
final isDesktop = isDisplayDesktop(context);
final safeAreaTopPadding = MediaQuery.of(context).padding.top;
final Widget frontLayer = ExcludeSemantics(
child: DefaultFocusTraversal(
policy: WidgetOrderFocusTraversalPolicy(),
child: Focus(
skipTraversal: !widget.isSettingsOpenNotifier.value,
child: widget.frontLayer,
),
),
excluding: !widget.isSettingsOpenNotifier.value,
);
final Widget backLayer = ExcludeSemantics(
child: widget.backLayer,
excluding: widget.isSettingsOpenNotifier.value,
);
return DefaultFocusTraversal(
child: InheritedBackdropFocusNodes(
frontLayerFocusNode: frontLayerFocusNode,
backLayerFocusNode: backLayerFocusNode,
child: Container(
child: Stack(
children: [
if (!isDesktop) ...[
_galleryHeader(),
frontLayer,
PositionedTransition(
rect: _getPanelAnimation(constraints),
child: backLayer,
),
],
if (isDesktop) ...[
_galleryHeader(),
backLayer,
if (widget.isSettingsOpenNotifier.value) ...[
ExcludeSemantics(
child: ModalBarrier(
dismissible: true,
),
),
Semantics(
label: GalleryLocalizations.of(context)
.settingsButtonCloseLabel,
child: GestureDetector(
onTap: toggleSettings,
),
)
],
ScaleTransition(
alignment: Directionality.of(context) == TextDirection.ltr
? Alignment.topRight
: Alignment.topLeft,
scale: CurvedAnimation(
parent: isDesktop
? widget.controller
: widget.openSettingsAnimation,
curve: Curves.easeIn,
reverseCurve: Curves.easeOut,
),
child: Align(
alignment: AlignmentDirectional.topEnd,
child: Material(
elevation: 7,
clipBehavior: Clip.antiAlias,
borderRadius: BorderRadius.circular(40),
color: Theme.of(context).colorScheme.secondaryVariant,
child: Container(
constraints: const BoxConstraints(
maxHeight: 560,
maxWidth: desktopSettingsWidth,
minWidth: desktopSettingsWidth,
),
child: frontLayer,
),
),
),
),
],
Align(
alignment: AlignmentDirectional.topEnd,
child: Semantics(
button: true,
label: widget.isSettingsOpenNotifier.value
? GalleryLocalizations.of(context)
.settingsButtonCloseLabel
: GalleryLocalizations.of(context).settingsButtonLabel,
child: SizedBox(
width: settingsButtonWidth,
height: isDesktop
? settingsButtonHeightDesktop
: settingsButtonHeightMobile + safeAreaTopPadding,
child: Material(
borderRadius: BorderRadiusDirectional.only(
bottomStart: Radius.circular(10),
),
color: widget.isSettingsOpenNotifier.value &
!widget.controller.isAnimating
? Colors.transparent
: Theme.of(context).colorScheme.secondaryVariant,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: toggleSettings,
child: Padding(
padding: const EdgeInsetsDirectional.only(
start: 3, end: 18),
child: Focus(
onFocusChange: (hasFocus) {
if (!hasFocus) {
FocusScope.of(context).requestFocus(
(widget.isSettingsOpenNotifier.value)
? frontLayerFocusNode
: backLayerFocusNode);
}
},
child: FlareActor(
Theme.of(context).colorScheme.brightness ==
Brightness.light
? 'assets/icons/settings/settings_light.flr'
: 'assets/icons/settings/settings_dark.flr',
alignment: Directionality.of(context) ==
TextDirection.ltr
? Alignment.bottomLeft
: Alignment.bottomRight,
fit: BoxFit.contain,
controller: this,
),
),
),
),
),
),
),
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: _buildStack,
);
}
}
class InheritedBackdropFocusNodes extends InheritedWidget {
InheritedBackdropFocusNodes({
@required Widget child,
@required this.frontLayerFocusNode,
@required this.backLayerFocusNode,
}) : assert(child != null),
super(child: child);
final FocusNode frontLayerFocusNode;
final FocusNode backLayerFocusNode;
static InheritedBackdropFocusNodes of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType();
@override
bool updateShouldNotify(InheritedWidget oldWidget) => true;
}

View File

@@ -0,0 +1,308 @@
// Copyright 2019 The Flutter team. 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/material.dart';
import 'package:gallery/data/demos.dart';
import 'package:gallery/layout/adaptive.dart';
import 'package:gallery/pages/demo.dart';
class CategoryListItem extends StatefulWidget {
const CategoryListItem({
Key key,
this.title,
this.imageString,
this.demos = const [],
}) : super(key: key);
final String title;
final String imageString;
final List<GalleryDemo> demos;
@override
_CategoryListItemState createState() => _CategoryListItemState();
}
class _CategoryListItemState extends State<CategoryListItem>
with SingleTickerProviderStateMixin {
static final Animatable<double> _easeInTween =
CurveTween(curve: Curves.easeIn);
static const _expandDuration = Duration(milliseconds: 200);
AnimationController _controller;
Animation<double> _childrenHeightFactor;
Animation<double> _headerChevronOpacity;
Animation<double> _headerHeight;
Animation<EdgeInsetsGeometry> _headerMargin;
Animation<EdgeInsetsGeometry> _headerImagePadding;
Animation<EdgeInsetsGeometry> _childrenPadding;
Animation<BorderRadius> _headerBorderRadius;
bool _isExpanded = false;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: _expandDuration, vsync: this);
_childrenHeightFactor = _controller.drive(_easeInTween);
_headerChevronOpacity = _controller.drive(_easeInTween);
_headerHeight = Tween<double>(
begin: 80,
end: 96,
).animate(_controller);
_headerMargin = EdgeInsetsGeometryTween(
begin: EdgeInsets.fromLTRB(32, 8, 32, 8),
end: EdgeInsets.zero,
).animate(_controller);
_headerImagePadding = EdgeInsetsGeometryTween(
begin: EdgeInsets.all(8),
end: EdgeInsetsDirectional.fromSTEB(16, 8, 8, 8),
).animate(_controller);
_childrenPadding = EdgeInsetsGeometryTween(
begin: EdgeInsets.symmetric(horizontal: 32),
end: EdgeInsets.zero,
).animate(_controller);
_headerBorderRadius = BorderRadiusTween(
begin: BorderRadius.circular(10),
end: BorderRadius.zero,
).animate(_controller);
if (_isExpanded) {
_controller.value = 1.0;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleTap() {
setState(() {
_isExpanded = !_isExpanded;
if (_isExpanded) {
_controller.forward();
} else {
_controller.reverse().then<void>((value) {
if (!mounted) {
return;
}
setState(() {
// Rebuild.
});
});
}
});
}
Widget _buildHeaderWithChildren(BuildContext context, Widget child) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_CategoryHeader(
margin: _headerMargin.value,
imagePadding: _headerImagePadding.value,
borderRadius: _headerBorderRadius.value,
height: _headerHeight.value,
chevronOpacity: _headerChevronOpacity.value,
imageString: widget.imageString,
title: widget.title,
onTap: _handleTap,
),
Padding(
padding: _childrenPadding.value,
child: ClipRect(
child: Align(
heightFactor: _childrenHeightFactor.value,
child: child,
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final bool closed = !_isExpanded && _controller.isDismissed;
return AnimatedBuilder(
animation: _controller.view,
builder: _buildHeaderWithChildren,
child: closed ? null : _ExpandedCategoryDemos(demos: widget.demos),
);
}
}
class _CategoryHeader extends StatelessWidget {
const _CategoryHeader({
Key key,
this.margin,
this.imagePadding,
this.borderRadius,
this.height,
this.chevronOpacity,
this.imageString,
this.title,
this.onTap,
}) : super(key: key);
final EdgeInsetsGeometry margin;
final EdgeInsetsGeometry imagePadding;
final double height;
final BorderRadiusGeometry borderRadius;
final String imageString;
final String title;
final double chevronOpacity;
final GestureTapCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
margin: margin,
child: Material(
shape: RoundedRectangleBorder(borderRadius: borderRadius),
color: colorScheme.onBackground,
clipBehavior: Clip.antiAlias,
child: Container(
width: MediaQuery.of(context).size.width,
child: InkWell(
onTap: onTap,
child: Row(
children: [
Expanded(
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Padding(
padding: imagePadding,
child: ExcludeSemantics(
child: Image.asset(
imageString,
width: 64,
height: 64,
),
),
),
Padding(
padding: const EdgeInsetsDirectional.only(start: 8),
child: Text(
title,
style: Theme.of(context).textTheme.headline.apply(
color: colorScheme.onSurface,
),
),
),
],
),
),
Opacity(
opacity: chevronOpacity,
child: chevronOpacity != 0
? Padding(
padding: const EdgeInsetsDirectional.only(
start: 8,
end: 32,
),
child: Icon(
Icons.keyboard_arrow_up,
color: colorScheme.onSurface,
),
)
: null,
),
],
),
),
),
),
);
}
}
class _ExpandedCategoryDemos extends StatelessWidget {
const _ExpandedCategoryDemos({
Key key,
this.demos,
}) : super(key: key);
final List<GalleryDemo> demos;
@override
Widget build(BuildContext context) {
return Column(
children: [
for (final demo in demos)
CategoryDemoItem(
demo: demo,
),
const SizedBox(height: 12), // Extra space below.
],
);
}
}
class CategoryDemoItem extends StatelessWidget {
const CategoryDemoItem({Key key, this.demo}) : super(key: key);
final GalleryDemo demo;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: Theme.of(context).colorScheme.surface,
child: MergeSemantics(
child: InkWell(
onTap: () {
Navigator.push<void>(
context,
MaterialPageRoute(builder: (context) => DemoPage(demo: demo)),
);
},
child: Padding(
padding: EdgeInsetsDirectional.only(
start: 32,
top: 20,
end: isDisplayDesktop(context) ? 16 : 8,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
demo.icon,
color: colorScheme.primary,
),
SizedBox(width: 40),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
demo.title,
style: textTheme.subhead
.apply(color: colorScheme.onSurface),
),
Text(
demo.subtitle,
style: textTheme.overline.apply(
color: colorScheme.onSurface.withOpacity(0.5),
),
),
SizedBox(height: 20),
Divider(
thickness: 1,
height: 1,
color: Theme.of(context).colorScheme.background,
),
],
),
),
],
),
),
),
),
);
}
}

764
gallery/lib/pages/demo.dart Normal file
View File

@@ -0,0 +1,764 @@
// Copyright 2019 The Flutter team. 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:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gallery/codeviewer/code_displayer.dart';
import 'package:gallery/codeviewer/code_style.dart';
import 'package:gallery/constants.dart';
import 'package:gallery/data/demos.dart';
import 'package:gallery/data/gallery_options.dart';
import 'package:gallery/feature_discovery/feature_discovery.dart';
import 'package:gallery/l10n/gallery_localizations.dart';
import 'package:gallery/layout/adaptive.dart';
import 'package:gallery/pages/splash.dart';
import 'package:gallery/themes/gallery_theme_data.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
const _demoViewedCountKey = 'demoViewedCountKey';
enum _DemoState {
normal,
options,
info,
code,
fullscreen,
}
class DemoPage extends StatefulWidget {
const DemoPage({
Key key,
@required this.demo,
}) : super(key: key);
final GalleryDemo demo;
@override
_DemoPageState createState() => _DemoPageState();
}
class _DemoPageState extends State<DemoPage> with TickerProviderStateMixin {
_DemoState _state = _DemoState.normal;
int _configIndex = 0;
bool _isDesktop;
bool _showFeatureHighlight = true;
int _demoViewedCount;
AnimationController _codeBackgroundColorController;
GalleryDemoConfiguration get _currentConfig {
return widget.demo.configurations[_configIndex];
}
bool get _hasOptions => widget.demo.configurations.length > 1;
bool get _isSupportedSharedPreferencesPlatform =>
!kIsWeb && (Platform.isAndroid || Platform.isIOS);
// Only show the feature highlight on Android/iOS in mobile layout and only on
// the first and forth time the demo page is viewed.
bool _showFeatureHighlightForPlatform(BuildContext context) {
return _showFeatureHighlight &&
_isSupportedSharedPreferencesPlatform &&
!isDisplayDesktop(context) &&
(_demoViewedCount != null &&
(_demoViewedCount == 0 || _demoViewedCount == 3));
}
@override
void initState() {
super.initState();
_codeBackgroundColorController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 300),
);
SharedPreferences.getInstance().then((preferences) {
setState(() {
_demoViewedCount = preferences.getInt(_demoViewedCountKey) ?? 0;
preferences.setInt(_demoViewedCountKey, _demoViewedCount + 1);
});
});
}
@override
void dispose() {
_codeBackgroundColorController.dispose();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_isDesktop == null) {
_isDesktop = isDisplayDesktop(context);
}
}
/// Sets state and updates the background color for code.
void setStateAndUpdate(VoidCallback callback) {
setState(() {
callback();
if (_state == _DemoState.code) {
_codeBackgroundColorController.forward();
} else {
_codeBackgroundColorController.reverse();
}
});
}
void _handleTap(_DemoState newState) {
// Do not allow normal state for desktop.
if (_state == newState && isDisplayDesktop(context)) {
if (_state == _DemoState.fullscreen) {
setStateAndUpdate(() {
_state = _hasOptions ? _DemoState.options : _DemoState.info;
});
}
return;
}
setStateAndUpdate(() {
_state = _state == newState ? _DemoState.normal : newState;
});
}
Future<void> _showDocumentation(BuildContext context) async {
final url = _currentConfig.documentationUrl;
if (url == null) {
return;
}
if (await canLaunch(url)) {
await launch(url);
} else {
await showDialog<void>(
context: context,
builder: (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 isDesktop = isDisplayDesktop(context);
if (_state == _DemoState.fullscreen && !isDesktop) {
// Do not allow fullscreen state for mobile.
_state = _DemoState.normal;
} else if (_state == _DemoState.normal && isDesktop) {
// Do not allow normal state for desktop.
_state = _hasOptions ? _DemoState.options : _DemoState.info;
} else if (isDesktop != this._isDesktop) {
this._isDesktop = isDesktop;
// When going from desktop to mobile, return to normal state.
if (!isDesktop) {
_state = _DemoState.normal;
}
}
}
@override
Widget build(BuildContext context) {
bool isDesktop = isDisplayDesktop(context);
_resolveState(context);
final colorScheme = Theme.of(context).colorScheme;
final iconColor = colorScheme.onSurface;
final selectedIconColor = colorScheme.primary;
final appBarPadding = isDesktop ? 20.0 : 0.0;
final appBar = AppBar(
backgroundColor: Colors.transparent,
leading: Padding(
padding: EdgeInsetsDirectional.only(start: appBarPadding),
child: IconButton(
icon: const BackButtonIcon(),
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
onPressed: () {
Navigator.maybePop(context);
},
),
),
actions: [
if (_hasOptions)
IconButton(
icon: FeatureDiscovery(
title: GalleryLocalizations.of(context).demoOptionsFeatureTitle,
description: GalleryLocalizations.of(context)
.demoOptionsFeatureDescription,
showOverlay: _showFeatureHighlightForPlatform(context),
color: colorScheme.primary,
onDismiss: () {
setState(() {
_showFeatureHighlight = false;
});
},
onTap: () {
setState(() {
_showFeatureHighlight = false;
});
},
child: Icon(
Icons.tune,
color: _state == _DemoState.options ||
_showFeatureHighlightForPlatform(context)
? selectedIconColor
: iconColor,
),
),
tooltip: GalleryLocalizations.of(context).demoOptionsTooltip,
onPressed: () => _handleTap(_DemoState.options),
),
IconButton(
icon: Icon(Icons.info),
tooltip: GalleryLocalizations.of(context).demoInfoTooltip,
color: _state == _DemoState.info ? selectedIconColor : iconColor,
onPressed: () => _handleTap(_DemoState.info),
),
IconButton(
icon: Icon(Icons.code),
tooltip: GalleryLocalizations.of(context).demoCodeTooltip,
color: _state == _DemoState.code ? selectedIconColor : iconColor,
onPressed: () => _handleTap(_DemoState.code),
),
IconButton(
icon: Icon(Icons.library_books),
tooltip: GalleryLocalizations.of(context).demoDocumentationTooltip,
color: iconColor,
onPressed: () => _showDocumentation(context),
),
if (isDesktop)
IconButton(
icon: Icon(Icons.fullscreen),
tooltip: GalleryLocalizations.of(context).demoFullscreenTooltip,
color:
_state == _DemoState.fullscreen ? selectedIconColor : iconColor,
onPressed: () => _handleTap(_DemoState.fullscreen),
),
SizedBox(width: appBarPadding),
],
);
final mediaQuery = MediaQuery.of(context);
final bottomSafeArea = mediaQuery.padding.bottom;
final contentHeight = mediaQuery.size.height -
mediaQuery.padding.top -
mediaQuery.padding.bottom -
appBar.preferredSize.height;
final maxSectionHeight = isDesktop ? contentHeight : contentHeight - 64;
final horizontalPadding = isDesktop ? mediaQuery.size.width * 0.12 : 0.0;
final maxSectionWidth = 420.0;
Widget section;
switch (_state) {
case _DemoState.options:
section = _DemoSectionOptions(
maxHeight: maxSectionHeight,
maxWidth: maxSectionWidth,
configurations: widget.demo.configurations,
configIndex: _configIndex,
onConfigChanged: (index) {
setStateAndUpdate(() {
_configIndex = index;
if (!isDesktop) {
_state = _DemoState.normal;
}
});
},
);
break;
case _DemoState.info:
section = _DemoSectionInfo(
maxHeight: maxSectionHeight,
maxWidth: maxSectionWidth,
title: _currentConfig.title,
description: _currentConfig.description,
);
break;
case _DemoState.code:
final TextStyle codeTheme = GoogleFonts.robotoMono(
fontSize: 12 * GalleryOptions.of(context).textScaleFactor(context),
);
section = CodeStyle(
baseStyle: codeTheme.copyWith(color: Color(0xFFFAFBFB)),
numberStyle: codeTheme.copyWith(color: Color(0xFFBD93F9)),
commentStyle: codeTheme.copyWith(color: Color(0xFF808080)),
keywordStyle: codeTheme.copyWith(color: Color(0xFF1CDEC9)),
stringStyle: codeTheme.copyWith(color: Color(0xFFFFA65C)),
punctuationStyle: codeTheme.copyWith(color: Color(0xFF8BE9FD)),
classStyle: codeTheme.copyWith(color: Color(0xFFD65BAD)),
constantStyle: codeTheme.copyWith(color: Color(0xFFFF8383)),
child: _DemoSectionCode(
maxHeight: maxSectionHeight,
codeWidget: CodeDisplayPage(
_currentConfig.code,
),
),
);
break;
default:
section = Container();
break;
}
Widget body;
Widget demoContent = DemoContent(
height: contentHeight,
buildRoute: _currentConfig.buildRoute,
);
if (isDesktop) {
final isFullScreen = _state == _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 {
section = AnimatedSize(
vsync: this,
duration: const Duration(milliseconds: 200),
alignment: Alignment.topCenter,
curve: Curves.easeIn,
child: section,
);
// Add a tap gesture to collapse the currently opened section.
demoContent = Semantics(
label: MaterialLocalizations.of(context).modalBarrierDismissLabel,
child: GestureDetector(
onTap: () {
if (_state != _DemoState.normal) {
setStateAndUpdate(() {
_state = _DemoState.normal;
});
}
},
child: Semantics(
excludeSemantics: _state != _DemoState.normal,
child: demoContent,
),
),
);
body = SafeArea(
bottom: false,
child: ListView(
// Use a non-scrollable ListView to enable animation of shifting the
// demo offscreen.
physics: NeverScrollableScrollPhysics(),
children: [
section,
demoContent,
// Fake the safe area to ensure the animation looks correct.
SizedBox(height: bottomSafeArea),
],
),
);
}
Widget page;
if (isDesktop) {
page = AnimatedBuilder(
animation: _codeBackgroundColorController,
builder: (context, child) {
Brightness themeBrightness;
switch (GalleryOptions.of(context).themeMode) {
case ThemeMode.system:
themeBrightness = MediaQuery.of(context).platformBrightness;
break;
case ThemeMode.light:
themeBrightness = Brightness.light;
break;
case ThemeMode.dark:
themeBrightness = Brightness.dark;
break;
}
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.
Widget codeBackground = Container(
padding: EdgeInsets.only(top: 56),
child: Container(
color: ColorTween(
begin: Colors.transparent,
end: GalleryThemeData.darkThemeData.canvasColor,
).animate(_codeBackgroundColorController).value,
),
);
contents = Stack(
children: [
codeBackground,
contents,
],
);
}
return Container(
color: colorScheme.background,
child: contents,
);
});
} else {
page = Container(
color: colorScheme.background,
child: ApplyTextOptions(
child: Scaffold(
appBar: appBar,
body: body,
),
),
);
}
// Add the splash page functionality for desktop.
if (isDesktop) {
page = MediaQuery.removePadding(
removeTop: true,
context: context,
child: SplashPage(
isAnimated: false,
child: page,
),
);
}
return FeatureDiscoveryController(page);
}
}
class _DemoSectionOptions extends StatelessWidget {
const _DemoSectionOptions({
Key key,
this.maxHeight,
this.maxWidth,
this.configurations,
this.configIndex,
this.onConfigChanged,
}) : super(key: key);
final double maxHeight;
final double maxWidth;
final List<GalleryDemoConfiguration> configurations;
final int configIndex;
final ValueChanged<int> onConfigChanged;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final 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.display1.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 configuration in configurations)
_DemoSectionOptionsItem(
title: configuration.title,
isSelected: configuration == configurations[configIndex],
onTap: () {
onConfigChanged(configurations.indexOf(configuration));
},
),
],
),
),
SizedBox(height: 12),
],
),
),
);
}
}
class _DemoSectionOptionsItem extends StatelessWidget {
const _DemoSectionOptionsItem({
Key key,
this.title,
this.isSelected,
this.onTap,
}) : super(key: key);
final String title;
final bool isSelected;
final GestureTapCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: isSelected ? colorScheme.surface : null,
child: InkWell(
onTap: onTap,
child: Container(
constraints: BoxConstraints(minWidth: double.infinity),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Text(
title,
style: Theme.of(context).textTheme.body1.apply(
color:
isSelected ? colorScheme.primary : colorScheme.onSurface,
),
),
),
),
);
}
}
class _DemoSectionInfo extends StatelessWidget {
const _DemoSectionInfo({
Key key,
this.maxHeight,
this.maxWidth,
this.title,
this.description,
}) : super(key: key);
final double maxHeight;
final double maxWidth;
final String title;
final String description;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final 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: [
Text(
title,
style: textTheme.display1.apply(
color: colorScheme.onSurface,
fontSizeDelta:
isDisplayDesktop(context) ? desktopDisplay1FontDelta : 0,
),
),
SizedBox(height: 12),
Text(
description,
style: textTheme.body1.apply(color: colorScheme.onSurface),
),
],
),
),
),
);
}
}
class DemoContent extends StatelessWidget {
const DemoContent({
Key key,
@required this.height,
@required this.buildRoute,
}) : super(key: key);
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: Material(
clipBehavior: Clip.antiAlias,
borderRadius: BorderRadius.vertical(
top: Radius.circular(10.0),
bottom: Radius.circular(2.0),
),
child: DemoWrapper(child: Builder(builder: buildRoute)),
),
);
}
}
class _DemoSectionCode extends StatelessWidget {
const _DemoSectionCode({
Key key,
this.maxHeight,
this.codeWidget,
}) : super(key: key);
final double maxHeight;
final Widget codeWidget;
@override
Widget build(BuildContext context) {
final isDesktop = isDisplayDesktop(context);
return Theme(
data: GalleryThemeData.darkThemeData,
child: Padding(
padding: EdgeInsets.only(bottom: 16),
child: Container(
color: isDesktop ? null : GalleryThemeData.darkThemeData.canvasColor,
padding: EdgeInsets.symmetric(horizontal: 16),
height: maxHeight,
child: codeWidget,
),
),
);
}
}
class CodeDisplayPage extends StatelessWidget {
const CodeDisplayPage(this.code);
final CodeDisplayer code;
Widget build(BuildContext context) {
final isDesktop = isDisplayDesktop(context);
final TextSpan _richTextCode = code(context);
final String _plainTextCode = _richTextCode.toPlainText();
void _showSnackBarOnCopySuccess(dynamic result) {
Scaffold.of(context).showSnackBar(
SnackBar(
content: Text(
GalleryLocalizations.of(context)
.demoCodeViewerCopiedToClipboardMessage,
),
),
);
}
void _showSnackBarOnCopyFailure(Object exception) {
Scaffold.of(context).showSnackBar(
SnackBar(
content: Text(
GalleryLocalizations.of(context)
.demoCodeViewerFailedToCopyToClipboardMessage(exception),
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: isDesktop
? EdgeInsets.only(bottom: 8)
: EdgeInsets.symmetric(vertical: 8),
child: FlatButton(
color: Colors.white.withOpacity(0.15),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: EdgeInsets.symmetric(horizontal: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(4)),
),
onPressed: () async {
// The future will not complete on web, thus no
// Snackbar will be shown, see https://github.com/flutter/flutter/issues/49349.
await Clipboard.setData(ClipboardData(text: _plainTextCode))
.then(_showSnackBarOnCopySuccess)
.catchError(_showSnackBarOnCopyFailure);
},
child: Text(
GalleryLocalizations.of(context).demoCodeViewerCopyAll,
style: Theme.of(context).textTheme.button.copyWith(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
),
Expanded(
child: SingleChildScrollView(
child: Container(
padding: EdgeInsets.symmetric(vertical: 8),
child: RichText(
textDirection: TextDirection.ltr,
text: _richTextCode,
),
),
),
),
],
);
}
}

944
gallery/lib/pages/home.dart Normal file
View File

@@ -0,0 +1,944 @@
// Copyright 2019 The Flutter team. 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:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/semantics.dart';
import 'package:gallery/constants.dart';
import 'package:gallery/data/demos.dart';
import 'package:gallery/data/gallery_options.dart';
import 'package:gallery/l10n/gallery_localizations.dart';
import 'package:gallery/layout/adaptive.dart';
import 'package:gallery/pages/backdrop.dart';
import 'package:gallery/pages/category_list_item.dart';
import 'package:gallery/pages/settings.dart';
import 'package:gallery/pages/splash.dart';
import 'package:gallery/studies/crane/app.dart';
import 'package:gallery/studies/crane/colors.dart';
import 'package:gallery/studies/fortnightly/app.dart';
import 'package:gallery/studies/rally/app.dart';
import 'package:gallery/studies/rally/colors.dart';
import 'package:gallery/studies/shrine/app.dart';
import 'package:gallery/studies/shrine/colors.dart';
import 'package:gallery/studies/starter/app.dart';
const _horizontalPadding = 32.0;
const _carouselItemMargin = 8.0;
const _horizontalDesktopPadding = 81.0;
const _carouselHeightMin = 200.0 + 2 * _carouselItemMargin;
const shrineTitle = 'Shrine';
const rallyTitle = 'Rally';
const craneTitle = 'Crane';
const homeCategoryMaterial = 'MATERIAL';
const homeCategoryCupertino = 'CUPERTINO';
class ToggleSplashNotification extends Notification {}
class NavigatorKeys {
static final shrine = GlobalKey<NavigatorState>();
static final rally = GlobalKey<NavigatorState>();
static final crane = GlobalKey<NavigatorState>();
static final fortnightly = GlobalKey<NavigatorState>();
static final starter = GlobalKey<NavigatorState>();
}
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var carouselHeight = _carouselHeight(.7, context);
final isDesktop = isDisplayDesktop(context);
final carouselCards = <_CarouselCard>[
_CarouselCard(
title: shrineTitle,
subtitle: GalleryLocalizations.of(context).shrineDescription,
asset: 'assets/studies/shrine_card.png',
assetDark: 'assets/studies/shrine_card_dark.png',
textColor: shrineBrown900,
study: ShrineApp(navigatorKey: NavigatorKeys.shrine),
navigatorKey: NavigatorKeys.shrine,
),
_CarouselCard(
title: rallyTitle,
subtitle: GalleryLocalizations.of(context).rallyDescription,
textColor: RallyColors.accountColors[0],
asset: 'assets/studies/rally_card.png',
assetDark: 'assets/studies/rally_card_dark.png',
study: RallyApp(navigatorKey: NavigatorKeys.rally),
navigatorKey: NavigatorKeys.rally,
),
_CarouselCard(
title: craneTitle,
subtitle: GalleryLocalizations.of(context).craneDescription,
asset: 'assets/studies/crane_card.png',
assetDark: 'assets/studies/crane_card_dark.png',
textColor: cranePurple700,
study: CraneApp(navigatorKey: NavigatorKeys.crane),
navigatorKey: NavigatorKeys.crane,
),
_CarouselCard(
title: fortnightlyTitle,
subtitle: GalleryLocalizations.of(context).fortnightlyDescription,
// TODO: Provide asset for study banner.
study: FortnightlyApp(navigatorKey: NavigatorKeys.fortnightly),
navigatorKey: NavigatorKeys.fortnightly,
),
_CarouselCard(
title: GalleryLocalizations.of(context).starterAppTitle,
subtitle: GalleryLocalizations.of(context).starterAppDescription,
asset: 'assets/studies/starter_card.png',
assetDark: 'assets/studies/starter_card_dark.png',
textColor: Colors.black,
study: StarterApp(navigatorKey: NavigatorKeys.starter),
navigatorKey: NavigatorKeys.starter,
),
];
if (isDesktop) {
final desktopCategoryItems = <_DesktopCategoryItem>[
_DesktopCategoryItem(
title: homeCategoryMaterial,
imageString: 'assets/icons/material/material.png',
demos: materialDemos(context),
),
_DesktopCategoryItem(
title: homeCategoryCupertino,
imageString: 'assets/icons/cupertino/cupertino.png',
demos: cupertinoDemos(context),
),
_DesktopCategoryItem(
title: GalleryLocalizations.of(context).homeCategoryReference,
imageString: 'assets/icons/reference/reference.png',
demos: referenceDemos(context),
),
];
return Scaffold(
body: ListView(
padding: EdgeInsetsDirectional.only(
start: _horizontalDesktopPadding,
top: isDesktop ? firstHeaderDesktopTopPadding : 21,
end: _horizontalDesktopPadding,
),
children: [
ExcludeSemantics(child: _GalleryHeader()),
/// TODO: When Focus widget becomes better remove dummy Focus
/// variable.
/// This [Focus] widget grabs focus from the settingsIcon,
/// when settings isn't open.
/// The container following the Focus widget isn't wrapped with
/// Focus because anytime FocusScope.of(context).requestFocus() the
/// focused widget will be skipped. We want to be able to focus on
/// the container which is why we created this Focus variable.
Focus(
focusNode:
InheritedBackdropFocusNodes.of(context).backLayerFocusNode,
child: SizedBox(),
),
Container(
height: carouselHeight,
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: spaceBetween(30, carouselCards),
),
),
_CategoriesHeader(),
Container(
height: 585,
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: spaceBetween(28, desktopCategoryItems),
),
),
Padding(
padding: const EdgeInsetsDirectional.only(
bottom: 81,
top: 109,
),
child: Row(
children: [
Image.asset(
Theme.of(context).colorScheme.brightness == Brightness.dark
? 'assets/logo/flutter_logo.png'
: 'assets/logo/flutter_logo_color.png',
excludeFromSemantics: true,
),
Expanded(
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.end,
children: [
SettingsAbout(),
SettingsFeedback(),
SettingsAttribution(),
],
),
),
],
),
),
],
),
);
} else {
return Scaffold(
body: _AnimatedHomePage(
isSplashPageAnimationFinished:
SplashPageAnimation.of(context).isFinished,
carouselCards: carouselCards,
),
);
}
}
List<Widget> spaceBetween(double paddingBetween, List<Widget> children) {
return [
for (int index = 0; index < children.length; index++) ...[
Flexible(
child: children[index],
),
if (index < children.length - 1) SizedBox(width: paddingBetween),
],
];
}
}
class _GalleryHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Header(
color: Theme.of(context).colorScheme.primaryVariant,
text: GalleryLocalizations.of(context).homeHeaderGallery,
);
}
}
class _CategoriesHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Header(
color: Theme.of(context).colorScheme.primary,
text: GalleryLocalizations.of(context).homeHeaderCategories,
);
}
}
class Header extends StatelessWidget {
const Header({this.color, this.text});
final Color color;
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
top: isDisplayDesktop(context) ? 63 : 15,
bottom: isDisplayDesktop(context) ? 21 : 11,
),
child: Text(
text,
style: Theme.of(context).textTheme.display1.apply(
color: color,
fontSizeDelta:
isDisplayDesktop(context) ? desktopDisplay1FontDelta : 0,
),
),
);
}
}
class _AnimatedHomePage extends StatefulWidget {
const _AnimatedHomePage({
Key key,
@required this.carouselCards,
@required this.isSplashPageAnimationFinished,
}) : super(key: key);
final List<Widget> carouselCards;
final bool isSplashPageAnimationFinished;
@override
_AnimatedHomePageState createState() => _AnimatedHomePageState();
}
class _AnimatedHomePageState extends State<_AnimatedHomePage>
with SingleTickerProviderStateMixin {
AnimationController _animationController;
Timer _launchTimer;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 800),
);
if (widget.isSplashPageAnimationFinished) {
// To avoid the animation from running when changing the window size from
// desktop to mobile, we do not animate our widget if the
// splash page animation is finished on initState.
_animationController.value = 1.0;
} else {
// Start our animation halfway through the splash page animation.
_launchTimer = Timer(
const Duration(
milliseconds: splashPageAnimationDurationInMilliseconds ~/ 2,
),
() {
_animationController.forward();
},
);
}
}
@override
dispose() {
_animationController.dispose();
_launchTimer?.cancel();
_launchTimer = null;
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
ListView(
children: [
SizedBox(height: 8),
Container(
margin: EdgeInsets.symmetric(horizontal: _horizontalPadding),
child: ExcludeSemantics(child: _GalleryHeader()),
),
_Carousel(
children: widget.carouselCards,
animationController: _animationController,
),
Container(
margin: EdgeInsets.symmetric(horizontal: _horizontalPadding),
child: _CategoriesHeader(),
),
_AnimatedCategoryItem(
startDelayFraction: 0.00,
controller: _animationController,
child: CategoryListItem(
title: homeCategoryMaterial,
imageString: 'assets/icons/material/material.png',
demos: materialDemos(context),
),
),
_AnimatedCategoryItem(
startDelayFraction: 0.05,
controller: _animationController,
child: CategoryListItem(
title: homeCategoryCupertino,
imageString: 'assets/icons/cupertino/cupertino.png',
demos: cupertinoDemos(context),
),
),
_AnimatedCategoryItem(
startDelayFraction: 0.10,
controller: _animationController,
child: CategoryListItem(
title: GalleryLocalizations.of(context).homeCategoryReference,
imageString: 'assets/icons/reference/reference.png',
demos: referenceDemos(context),
),
),
],
),
Align(
alignment: Alignment.topCenter,
child: GestureDetector(
onVerticalDragEnd: (details) {
if (details.velocity.pixelsPerSecond.dy > 200) {
ToggleSplashNotification()..dispatch(context);
}
},
child: SafeArea(
child: Container(
height: 40,
// If we don't set the color, gestures are not detected.
color: Colors.transparent,
),
),
),
),
],
);
}
}
class _DesktopCategoryItem extends StatelessWidget {
const _DesktopCategoryItem({
this.title,
this.imageString,
this.demos,
});
final String title;
final String imageString;
final List<GalleryDemo> demos;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
borderRadius: BorderRadius.circular(10),
clipBehavior: Clip.antiAlias,
color: colorScheme.surface,
child: Semantics(
container: true,
child: DefaultFocusTraversal(
policy: WidgetOrderFocusTraversalPolicy(),
child: Column(
children: [
_DesktopCategoryHeader(
title: title,
imageString: imageString,
),
Divider(
height: 2,
thickness: 2,
color: colorScheme.background,
),
Flexible(
// Remove ListView top padding as it is already accounted for.
child: MediaQuery.removePadding(
removeTop: true,
context: context,
child: ListView(
children: [
const SizedBox(height: 12),
for (GalleryDemo demo in demos)
CategoryDemoItem(
demo: demo,
),
SizedBox(height: 12),
],
),
),
),
],
),
),
),
);
}
}
class _DesktopCategoryHeader extends StatelessWidget {
const _DesktopCategoryHeader({
this.title,
this.imageString,
});
final String title;
final String imageString;
@override
Widget build(BuildContext context) {
ColorScheme colorScheme = Theme.of(context).colorScheme;
return Material(
color: colorScheme.onBackground,
child: Row(
children: [
Padding(
padding: EdgeInsets.all(10),
child: Image.asset(
imageString,
width: 64,
height: 64,
excludeFromSemantics: true,
),
),
Flexible(
child: Padding(
padding: EdgeInsetsDirectional.only(start: 8),
child: Semantics(
header: true,
child: Text(
title,
style: Theme.of(context).textTheme.headline.apply(
color: colorScheme.onSurface,
),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
),
),
),
],
),
);
}
}
/// Animates the category item to stagger in. The [_AnimatedCategoryItem.startDelayFraction]
/// gives a delay in the unit of a fraction of the whole animation duration,
/// which is defined in [_AnimatedHomePageState].
class _AnimatedCategoryItem extends StatelessWidget {
_AnimatedCategoryItem({
Key key,
double startDelayFraction,
@required this.controller,
@required this.child,
}) : topPaddingAnimation = Tween(
begin: 60.0,
end: 0.0,
).animate(
CurvedAnimation(
parent: controller,
curve: Interval(
0.000 + startDelayFraction,
0.400 + startDelayFraction,
curve: Curves.ease,
),
),
),
super(key: key);
final Widget child;
final AnimationController controller;
final Animation<double> topPaddingAnimation;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, child) {
return Padding(
padding: EdgeInsets.only(top: topPaddingAnimation.value),
child: child,
);
},
child: child,
);
}
}
/// Animates the carousel to come in from the right.
class _AnimatedCarousel extends StatelessWidget {
_AnimatedCarousel({
Key key,
@required this.child,
@required this.controller,
}) : startPositionAnimation = Tween(
begin: 1.0,
end: 0.0,
).animate(
CurvedAnimation(
parent: controller,
curve: Interval(
0.200,
0.800,
curve: Curves.ease,
),
),
),
super(key: key);
final Widget child;
final AnimationController controller;
final Animation<double> startPositionAnimation;
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
return Stack(
children: [
SizedBox(height: _carouselHeight(.4, context)),
AnimatedBuilder(
animation: controller,
builder: (context, child) {
return PositionedDirectional(
start: constraints.maxWidth * startPositionAnimation.value,
child: child,
);
},
child: Container(
height: _carouselHeight(.4, context),
width: constraints.maxWidth,
child: child,
),
),
],
);
});
}
}
/// Animates a carousel card to come in from the right.
class _AnimatedCarouselCard extends StatelessWidget {
_AnimatedCarouselCard({
Key key,
@required this.child,
@required this.controller,
}) : startPaddingAnimation = Tween(
begin: _horizontalPadding,
end: 0.0,
).animate(
CurvedAnimation(
parent: controller,
curve: Interval(
0.900,
1.000,
curve: Curves.ease,
),
),
),
super(key: key);
final Widget child;
final AnimationController controller;
final Animation<double> startPaddingAnimation;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, child) {
return Padding(
padding: EdgeInsetsDirectional.only(
start: startPaddingAnimation.value,
),
child: child,
);
},
child: child,
);
}
}
class _Carousel extends StatefulWidget {
const _Carousel({
Key key,
this.children,
this.animationController,
}) : super(key: key);
final List<Widget> children;
final AnimationController animationController;
@override
_CarouselState createState() => _CarouselState();
}
class _CarouselState extends State<_Carousel>
with SingleTickerProviderStateMixin {
PageController _controller;
int _currentPage = 0;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_controller == null) {
// The viewPortFraction is calculated as the width of the device minus the
// padding.
final width = MediaQuery.of(context).size.width;
final padding = (_horizontalPadding * 2) - (_carouselItemMargin * 2);
_controller = PageController(
initialPage: _currentPage,
viewportFraction: (width - padding) / width,
);
}
}
@override
dispose() {
_controller.dispose();
super.dispose();
}
Widget builder(int index) {
final carouselCard = AnimatedBuilder(
animation: _controller,
builder: (context, child) {
double value;
if (_controller.position.haveDimensions) {
value = _controller.page - index;
} else {
// If haveDimensions is false, use _currentPage to calculate value.
value = (_currentPage - index).toDouble();
}
// We want the peeking cards to be 160 in height and 0.38 helps
// achieve that.
value = (1 - (value.abs() * .38)).clamp(0, 1).toDouble();
value = Curves.easeOut.transform(value);
return Center(
child: Transform(
transform: Matrix4.diagonal3Values(1.0, value, 1.0),
alignment: Alignment.center,
child: child,
),
);
},
child: widget.children[index],
);
// We only want the second card to be animated.
if (index == 1) {
return _AnimatedCarouselCard(
child: carouselCard,
controller: widget.animationController,
);
} else {
return carouselCard;
}
}
@override
Widget build(BuildContext context) {
return _AnimatedCarousel(
child: PageView.builder(
onPageChanged: (value) {
setState(() {
_currentPage = value;
});
},
controller: _controller,
itemCount: widget.children.length,
itemBuilder: (context, index) => builder(index),
),
controller: widget.animationController,
);
}
}
class _CarouselCard extends StatelessWidget {
const _CarouselCard({
Key key,
this.title,
this.subtitle,
this.asset,
this.assetDark,
this.textColor,
this.study,
this.navigatorKey,
}) : super(key: key);
final String title;
final String subtitle;
final String asset;
final String assetDark;
final Color textColor;
final Widget study;
final GlobalKey<NavigatorState> navigatorKey;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
// TODO: Update with newer assets when we have them. For now, always use the
// dark assets.
// Theme.of(context).colorScheme.brightness == Brightness.dark;
final isDark = true;
final asset = isDark ? assetDark : this.asset;
final textColor = isDark ? Colors.white.withOpacity(0.87) : this.textColor;
return Container(
margin:
EdgeInsets.all(isDisplayDesktop(context) ? 0 : _carouselItemMargin),
child: Material(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
clipBehavior: Clip.antiAlias,
color: Colors.grey,
child: InkWell(
onTap: () {
Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (context) => _StudyWrapper(
study: study,
navigatorKey: navigatorKey,
),
),
);
},
child: Stack(
fit: StackFit.expand,
children: [
if (asset != null)
Ink.image(
image: AssetImage(asset),
fit: BoxFit.cover,
),
Padding(
padding: EdgeInsetsDirectional.fromSTEB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
title,
style: textTheme.caption.apply(color: textColor),
maxLines: 3,
overflow: TextOverflow.visible,
),
Text(
subtitle,
style: textTheme.overline.apply(color: textColor),
maxLines: 5,
overflow: TextOverflow.visible,
),
],
),
),
],
),
),
),
);
}
}
double _carouselHeight(double scaleFactor, BuildContext context) => math.max(
_carouselHeightMin *
GalleryOptions.of(context).textScaleFactor(context) *
scaleFactor,
_carouselHeightMin);
/// Wrap the studies with this to display a back button and allow the user to
/// exit them at any time.
class _StudyWrapper extends StatefulWidget {
const _StudyWrapper({
Key key,
this.study,
this.navigatorKey,
}) : super(key: key);
final Widget study;
final GlobalKey<NavigatorState> navigatorKey;
@override
_StudyWrapperState createState() => _StudyWrapperState();
}
class _StudyWrapperState extends State<_StudyWrapper> {
FocusNode backButtonFocusNode;
@override
void initState() {
super.initState();
backButtonFocusNode = FocusNode();
}
@override
void dispose() {
backButtonFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return ApplyTextOptions(
child: DefaultFocusTraversal(
policy: StudyWrapperFocusTraversalPolicy(
backButtonFocusNode: backButtonFocusNode,
studyNavigatorKey: widget.navigatorKey,
),
child: InheritedFocusNodes(
backButtonFocusNode: backButtonFocusNode,
child: Stack(
children: [
Semantics(
sortKey: const OrdinalSortKey(1),
child: widget.study,
),
Align(
alignment: AlignmentDirectional.bottomStart,
child: Padding(
padding: const EdgeInsets.all(16),
child: Semantics(
sortKey: const OrdinalSortKey(0),
label: GalleryLocalizations.of(context).backToGallery,
button: true,
excludeSemantics: true,
child: FloatingActionButton.extended(
focusNode: backButtonFocusNode,
onPressed: () {
Navigator.of(context).pop();
},
icon: IconTheme(
data: IconThemeData(color: colorScheme.onPrimary),
child: BackButtonIcon(),
),
label: Text(
MaterialLocalizations.of(context).backButtonTooltip,
style: textTheme.button
.apply(color: colorScheme.onPrimary),
),
),
),
),
),
],
),
),
),
);
}
}
class InheritedFocusNodes extends InheritedWidget {
const InheritedFocusNodes({
Key key,
@required Widget child,
@required this.backButtonFocusNode,
}) : assert(child != null),
super(key: key, child: child);
final FocusNode backButtonFocusNode;
static InheritedFocusNodes of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType();
@override
bool updateShouldNotify(InheritedFocusNodes old) => true;
}
class StudyWrapperFocusTraversalPolicy extends WidgetOrderFocusTraversalPolicy {
StudyWrapperFocusTraversalPolicy({
@required this.backButtonFocusNode,
@required this.studyNavigatorKey,
});
final FocusNode backButtonFocusNode;
final GlobalKey<NavigatorState> studyNavigatorKey;
FocusNode _firstFocusNode() {
return studyNavigatorKey.currentState.focusScopeNode.traversalDescendants
.toList()
.first;
}
@override
bool previous(FocusNode currentNode) {
if (currentNode == backButtonFocusNode) {
return super.previous(_firstFocusNode());
} else {
return super.previous(currentNode);
}
}
@override
bool next(FocusNode currentNode) {
if (currentNode == backButtonFocusNode) {
_firstFocusNode().requestFocus();
return true;
} else {
return super.next(currentNode);
}
}
}

View File

@@ -0,0 +1,507 @@
// Copyright 2019 The Flutter team. 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:collection';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localized_locales/flutter_localized_locales.dart';
import 'package:gallery/constants.dart';
import 'package:gallery/data/gallery_options.dart';
import 'package:gallery/l10n/gallery_localizations.dart';
import 'package:gallery/layout/adaptive.dart';
import 'package:gallery/pages/about.dart' as about;
import 'package:gallery/pages/backdrop.dart';
import 'package:gallery/pages/home.dart';
import 'package:gallery/pages/settings_list_item.dart';
import 'package:url_launcher/url_launcher.dart';
enum _ExpandableSetting {
textScale,
textDirection,
locale,
platform,
theme,
}
class SettingsPage extends StatefulWidget {
SettingsPage({
Key key,
@required this.openSettingsAnimation,
@required this.staggerSettingsItemsAnimation,
@required this.isSettingsOpenNotifier,
}) : super(key: key);
final Animation<double> openSettingsAnimation;
final Animation<double> staggerSettingsItemsAnimation;
final ValueNotifier<bool> isSettingsOpenNotifier;
@override
_SettingsPageState createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
_ExpandableSetting expandedSettingId;
Map<String, String> _localeNativeNames;
void onTapSetting(_ExpandableSetting settingId) {
setState(() {
if (expandedSettingId == settingId) {
expandedSettingId = null;
} else {
expandedSettingId = settingId;
}
});
}
@override
void initState() {
super.initState();
LocaleNamesLocalizationsDelegate().allNativeNames().then(
(data) => setState(
() {
_localeNativeNames = data;
},
),
);
// When closing settings, also shrink expanded setting.
widget.isSettingsOpenNotifier.addListener(() {
if (!widget.isSettingsOpenNotifier.value) {
setState(() {
expandedSettingId = null;
});
}
});
}
/// Given a [Locale], returns a [DisplayOption] with its native name for a
/// title and its name in the currently selected locale for a subtitle. If the
/// native name can't be determined, it is omitted. If the locale can't be
/// determined, the locale code is used.
DisplayOption _getLocaleDisplayOption(BuildContext context, Locale locale) {
// TODO: gsw, fil, and es_419 aren't in flutter_localized_countries' dataset
final localeCode = locale.toString();
final localeName = LocaleNames.of(context).nameOf(localeCode);
if (localeName != null) {
final localeNativeName =
_localeNativeNames != null ? _localeNativeNames[localeCode] : null;
return localeNativeName != null
? DisplayOption(localeNativeName, subtitle: localeName)
: DisplayOption(localeName);
} else {
switch (localeCode) {
case 'gsw':
return DisplayOption('Schwiizertüütsch', subtitle: 'Swiss German');
case 'fil':
return DisplayOption('Filipino', subtitle: 'Filipino');
case 'es_419':
return DisplayOption(
'español (Latinoamérica)',
subtitle: 'Spanish (Latin America)',
);
}
}
return DisplayOption(localeCode);
}
/// Create a sorted — by native name map of supported locales to their
/// intended display string, with a system option as the first element.
LinkedHashMap<Locale, DisplayOption> _getLocaleOptions() {
var localeOptions = LinkedHashMap.of({
systemLocaleOption: DisplayOption(
GalleryLocalizations.of(context).settingsSystemDefault +
(deviceLocale != null
? ' - ${_getLocaleDisplayOption(context, deviceLocale).title}'
: ''),
),
});
var supportedLocales =
List<Locale>.from(GalleryLocalizations.supportedLocales);
supportedLocales.removeWhere((locale) => locale == deviceLocale);
final displayLocales = Map<Locale, DisplayOption>.fromIterable(
supportedLocales,
value: (dynamic locale) =>
_getLocaleDisplayOption(context, locale as Locale),
).entries.toList()
..sort((l1, l2) => compareAsciiUpperCase(l1.value.title, l2.value.title));
localeOptions.addAll(LinkedHashMap.fromEntries(displayLocales));
return localeOptions;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final options = GalleryOptions.of(context);
final isDesktop = isDisplayDesktop(context);
final settingsListItems = [
SettingsListItem<double>(
title: GalleryLocalizations.of(context).settingsTextScaling,
selectedOption: options.textScaleFactor(
context,
useSentinel: true,
),
options: LinkedHashMap.of({
systemTextScaleFactorOption: DisplayOption(
GalleryLocalizations.of(context).settingsSystemDefault,
),
0.8: DisplayOption(
GalleryLocalizations.of(context).settingsTextScalingSmall,
),
1.0: DisplayOption(
GalleryLocalizations.of(context).settingsTextScalingNormal,
),
2.0: DisplayOption(
GalleryLocalizations.of(context).settingsTextScalingLarge,
),
3.0: DisplayOption(
GalleryLocalizations.of(context).settingsTextScalingHuge,
),
}),
onOptionChanged: (newTextScale) => GalleryOptions.update(
context,
options.copyWith(textScaleFactor: newTextScale),
),
onTapSetting: () => onTapSetting(_ExpandableSetting.textScale),
isExpanded: expandedSettingId == _ExpandableSetting.textScale,
),
SettingsListItem<CustomTextDirection>(
title: GalleryLocalizations.of(context).settingsTextDirection,
selectedOption: options.customTextDirection,
options: LinkedHashMap.of({
CustomTextDirection.localeBased: DisplayOption(
GalleryLocalizations.of(context).settingsTextDirectionLocaleBased,
),
CustomTextDirection.ltr: DisplayOption(
GalleryLocalizations.of(context).settingsTextDirectionLTR,
),
CustomTextDirection.rtl: DisplayOption(
GalleryLocalizations.of(context).settingsTextDirectionRTL,
),
}),
onOptionChanged: (newTextDirection) => GalleryOptions.update(
context,
options.copyWith(customTextDirection: newTextDirection),
),
onTapSetting: () => onTapSetting(_ExpandableSetting.textDirection),
isExpanded: expandedSettingId == _ExpandableSetting.textDirection,
),
SettingsListItem<Locale>(
title: GalleryLocalizations.of(context).settingsLocale,
selectedOption: options.locale == deviceLocale
? systemLocaleOption
: options.locale,
options: _getLocaleOptions(),
onOptionChanged: (newLocale) {
if (newLocale == systemLocaleOption) {
newLocale = deviceLocale;
}
GalleryOptions.update(
context,
options.copyWith(locale: newLocale),
);
},
onTapSetting: () => onTapSetting(_ExpandableSetting.locale),
isExpanded: expandedSettingId == _ExpandableSetting.locale,
),
SettingsListItem<TargetPlatform>(
title: GalleryLocalizations.of(context).settingsPlatformMechanics,
selectedOption: options.platform,
options: LinkedHashMap.of({
TargetPlatform.android: DisplayOption(
GalleryLocalizations.of(context).settingsPlatformAndroid,
),
TargetPlatform.iOS: DisplayOption(
GalleryLocalizations.of(context).settingsPlatformIOS,
),
}),
onOptionChanged: (newPlatform) => GalleryOptions.update(
context,
options.copyWith(platform: newPlatform),
),
onTapSetting: () => onTapSetting(_ExpandableSetting.platform),
isExpanded: expandedSettingId == _ExpandableSetting.platform,
),
SettingsListItem<ThemeMode>(
title: GalleryLocalizations.of(context).settingsTheme,
selectedOption: options.themeMode,
options: LinkedHashMap.of({
ThemeMode.system: DisplayOption(
GalleryLocalizations.of(context).settingsSystemDefault,
),
ThemeMode.dark: DisplayOption(
GalleryLocalizations.of(context).settingsDarkTheme,
),
ThemeMode.light: DisplayOption(
GalleryLocalizations.of(context).settingsLightTheme,
),
}),
onOptionChanged: (newThemeMode) => GalleryOptions.update(
context,
options.copyWith(themeMode: newThemeMode),
),
onTapSetting: () => onTapSetting(_ExpandableSetting.theme),
isExpanded: expandedSettingId == _ExpandableSetting.theme,
),
SlowMotionSetting(),
];
return Material(
color: colorScheme.secondaryVariant,
child: _AnimatedSettingsPage(
animation: widget.openSettingsAnimation,
child: Padding(
padding: isDesktop
? EdgeInsets.zero
: EdgeInsets.only(
bottom: galleryHeaderHeight,
),
// Remove ListView top padding as it is already accounted for.
child: MediaQuery.removePadding(
removeTop: isDesktop,
context: context,
child: ListView(
children: [
if (isDesktop) SizedBox(height: firstHeaderDesktopTopPadding),
Focus(
focusNode: InheritedBackdropFocusNodes.of(context)
.frontLayerFocusNode,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 32),
child: ExcludeSemantics(
child: Header(
color: Theme.of(context).colorScheme.onSurface,
text: GalleryLocalizations.of(context).settingsTitle,
),
),
),
),
if (isDesktop)
...settingsListItems
else ...[
_AnimateSettingsListItems(
animation: widget.staggerSettingsItemsAnimation,
children: settingsListItems,
),
SizedBox(height: 16),
Divider(
thickness: 2, height: 0, color: colorScheme.background),
SizedBox(height: 12),
SettingsAbout(),
SettingsFeedback(),
SizedBox(height: 12),
Divider(
thickness: 2, height: 0, color: colorScheme.background),
SettingsAttribution(),
],
],
),
),
),
),
);
}
}
class SettingsAbout extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _SettingsLink(
title: GalleryLocalizations.of(context).settingsAbout,
icon: Icons.info_outline,
onTap: () {
about.showAboutDialog(context: context);
},
);
}
}
class SettingsFeedback extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _SettingsLink(
title: GalleryLocalizations.of(context).settingsFeedback,
icon: Icons.feedback,
onTap: () async {
final url = 'https://github.com/flutter/flutter/issues/new/choose/';
if (await canLaunch(url)) {
await launch(
url,
forceSafariVC: false,
);
}
},
);
}
}
class SettingsAttribution extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDesktop = isDisplayDesktop(context);
final verticalPadding = isDesktop ? 0.0 : 28.0;
return MergeSemantics(
child: Padding(
padding: EdgeInsetsDirectional.only(
start: isDesktop ? 24 : 32,
end: isDesktop ? 0 : 32,
top: verticalPadding,
bottom: verticalPadding,
),
child: Text(
GalleryLocalizations.of(context).settingsAttribution,
style: Theme.of(context).textTheme.body2.copyWith(
fontSize: 12,
color: Theme.of(context).colorScheme.onSecondary,
),
textAlign: isDesktop ? TextAlign.end : TextAlign.start,
),
),
);
}
}
class _SettingsLink extends StatelessWidget {
final String title;
final IconData icon;
final GestureTapCallback onTap;
_SettingsLink({this.title, this.icon, this.onTap});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final isDesktop = isDisplayDesktop(context);
return InkWell(
onTap: onTap,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? 24 : 32,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: colorScheme.onSecondary.withOpacity(0.5),
size: 24,
),
Flexible(
child: Padding(
padding: const EdgeInsetsDirectional.only(
start: 16,
top: 12,
bottom: 12,
),
child: Text(
title,
style: textTheme.subtitle.apply(
color: colorScheme.onSecondary,
),
textAlign: isDesktop ? TextAlign.end : TextAlign.start,
),
),
),
],
),
),
);
}
}
/// Animate the settings page to slide in from above.
class _AnimatedSettingsPage extends StatelessWidget {
const _AnimatedSettingsPage({
Key key,
@required this.animation,
@required this.child,
}) : super(key: key);
final Widget child;
final Animation<double> animation;
@override
Widget build(BuildContext context) {
final isDesktop = isDisplayDesktop(context);
if (isDesktop) {
return child;
} else {
return LayoutBuilder(builder: (context, constraints) {
return Stack(
children: [
PositionedTransition(
rect: RelativeRectTween(
begin: RelativeRect.fromLTRB(0, -constraints.maxHeight, 0, 0),
end: RelativeRect.fromLTRB(0, 0, 0, 0),
).animate(
CurvedAnimation(
parent: animation,
curve: Curves.linear,
),
),
child: child,
),
],
);
});
}
}
}
/// Animate the settings list items to stagger in from above.
class _AnimateSettingsListItems extends StatelessWidget {
const _AnimateSettingsListItems({
Key key,
this.animation,
this.children,
this.topPadding,
this.bottomPadding,
}) : super(key: key);
final Animation<double> animation;
final List<Widget> children;
final double topPadding;
final double bottomPadding;
@override
Widget build(BuildContext context) {
final startDividingPadding = 4.0;
final topPaddingTween = Tween<double>(
begin: 0,
end: children.length * startDividingPadding,
);
final dividerTween = Tween<double>(
begin: startDividingPadding,
end: 0,
);
return Padding(
padding: EdgeInsets.only(top: topPaddingTween.animate(animation).value),
child: Column(
children: [
for (Widget child in children)
AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Padding(
padding: EdgeInsets.only(
top: dividerTween.animate(animation).value,
),
child: child,
);
},
child: child,
),
],
),
);
}
}

View File

@@ -0,0 +1,334 @@
// Copyright 2019 The Flutter team. 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:collection';
import 'package:flutter/material.dart';
import 'package:gallery/data/gallery_options.dart';
import 'package:gallery/l10n/gallery_localizations.dart';
// Common constants between SlowMotionSetting and SettingsListItem.
final settingItemBorderRadius = BorderRadius.circular(10);
const settingItemHeaderMargin = EdgeInsetsDirectional.fromSTEB(32, 0, 32, 8);
class DisplayOption {
final String title;
final String subtitle;
DisplayOption(this.title, {this.subtitle});
}
class SlowMotionSetting extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final options = GalleryOptions.of(context);
return Semantics(
container: true,
child: Container(
margin: settingItemHeaderMargin,
child: Material(
shape: RoundedRectangleBorder(borderRadius: settingItemBorderRadius),
color: colorScheme.secondary,
clipBehavior: Clip.antiAlias,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
GalleryLocalizations.of(context).settingsSlowMotion,
style: textTheme.subhead.apply(
color: colorScheme.onSurface,
),
),
],
),
),
),
Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
child: Switch(
activeColor: colorScheme.primary,
value: options.timeDilation != 1.0,
onChanged: (isOn) => GalleryOptions.update(
context,
options.copyWith(timeDilation: isOn ? 5.0 : 1.0),
),
),
),
],
),
),
),
);
}
}
class SettingsListItem<T> extends StatefulWidget {
SettingsListItem({
Key key,
@required this.title,
@required this.options,
@required this.selectedOption,
@required this.onOptionChanged,
@required this.onTapSetting,
@required this.isExpanded,
}) : super(key: key);
final String title;
final LinkedHashMap<T, DisplayOption> options;
final T selectedOption;
final ValueChanged<T> onOptionChanged;
final Function onTapSetting;
final bool isExpanded;
@override
_SettingsListItemState createState() => _SettingsListItemState<T>();
}
class _SettingsListItemState<T> extends State<SettingsListItem<T>>
with SingleTickerProviderStateMixin {
static final Animatable<double> _easeInTween =
CurveTween(curve: Curves.easeIn);
static const _expandDuration = Duration(milliseconds: 150);
AnimationController _controller;
Animation<double> _childrenHeightFactor;
Animation<double> _headerChevronRotation;
Animation<double> _headerSubtitleHeight;
Animation<EdgeInsetsGeometry> _headerMargin;
Animation<EdgeInsetsGeometry> _headerPadding;
Animation<EdgeInsetsGeometry> _childrenPadding;
Animation<BorderRadius> _headerBorderRadius;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: _expandDuration, vsync: this);
_childrenHeightFactor = _controller.drive(_easeInTween);
_headerChevronRotation =
Tween<double>(begin: 0, end: 0.5).animate(_controller);
_headerMargin = EdgeInsetsGeometryTween(
begin: settingItemHeaderMargin,
end: EdgeInsets.zero,
).animate(_controller);
_headerPadding = EdgeInsetsGeometryTween(
begin: EdgeInsetsDirectional.fromSTEB(16, 10, 0, 10),
end: EdgeInsetsDirectional.fromSTEB(32, 18, 32, 20),
).animate(_controller);
_headerSubtitleHeight =
_controller.drive(Tween<double>(begin: 1.0, end: 0.0));
_childrenPadding = EdgeInsetsGeometryTween(
begin: EdgeInsets.symmetric(horizontal: 32),
end: EdgeInsets.zero,
).animate(_controller);
_headerBorderRadius = BorderRadiusTween(
begin: settingItemBorderRadius,
end: BorderRadius.zero,
).animate(_controller);
if (widget.isExpanded) {
_controller.value = 1.0;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleExpansion() {
if (widget.isExpanded) {
_controller.forward();
} else {
_controller.reverse().then<void>((value) {
if (!mounted) {
return;
}
});
}
}
Widget _buildHeaderWithChildren(BuildContext context, Widget child) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_CategoryHeader(
margin: _headerMargin.value,
padding: _headerPadding.value,
borderRadius: _headerBorderRadius.value,
subtitleHeight: _headerSubtitleHeight,
chevronRotation: _headerChevronRotation,
title: widget.title,
subtitle: widget.options[widget.selectedOption].title ?? '',
onTap: () => widget.onTapSetting(),
),
Padding(
padding: _childrenPadding.value,
child: ClipRect(
child: Align(
heightFactor: _childrenHeightFactor.value,
child: child,
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
_handleExpansion();
final theme = Theme.of(context);
final optionWidgetsList = <Widget>[];
widget.options.forEach(
(optionValue, optionDisplay) => optionWidgetsList.add(
RadioListTile<T>(
value: optionValue,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
optionDisplay.title,
style: theme.textTheme.body2.copyWith(
color: Theme.of(context).colorScheme.onPrimary,
),
),
if (optionDisplay.subtitle != null)
Text(
optionDisplay.subtitle,
style: theme.textTheme.body2.copyWith(
fontSize: 12,
color: Theme.of(context)
.colorScheme
.onPrimary
.withOpacity(0.8),
),
),
],
),
groupValue: widget.selectedOption,
onChanged: (newOption) => widget.onOptionChanged(newOption),
activeColor: Theme.of(context).colorScheme.primary,
dense: true,
),
),
);
return AnimatedBuilder(
animation: _controller.view,
builder: _buildHeaderWithChildren,
child: Container(
margin: const EdgeInsetsDirectional.only(start: 24, bottom: 40),
decoration: BoxDecoration(
border: BorderDirectional(
start: BorderSide(
width: 2,
color: theme.colorScheme.background,
),
),
),
child: ListView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => optionWidgetsList[index],
itemCount: optionWidgetsList.length,
),
),
);
}
}
class _CategoryHeader extends StatelessWidget {
const _CategoryHeader({
Key key,
this.margin,
this.padding,
this.borderRadius,
this.subtitleHeight,
this.chevronRotation,
this.title,
this.subtitle,
this.onTap,
}) : super(key: key);
final EdgeInsetsGeometry margin;
final EdgeInsetsGeometry padding;
final BorderRadiusGeometry borderRadius;
final String title;
final String subtitle;
final Animation<double> subtitleHeight;
final Animation<double> chevronRotation;
final GestureTapCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Container(
margin: margin,
child: Material(
shape: RoundedRectangleBorder(borderRadius: borderRadius),
color: colorScheme.secondary,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Padding(
padding: padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
style: textTheme.subhead.apply(
color: colorScheme.onSurface,
),
),
SizeTransition(
sizeFactor: subtitleHeight,
child: Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.overline.apply(
color: colorScheme.primary,
),
),
)
],
),
),
),
Padding(
padding: const EdgeInsetsDirectional.only(
start: 8,
end: 24,
),
child: RotationTransition(
turns: chevronRotation,
child: Icon(Icons.arrow_drop_down),
),
)
],
),
),
),
);
}
}

View File

@@ -0,0 +1,238 @@
// Copyright 2019 The Flutter team. 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:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:gallery/constants.dart';
import 'package:gallery/layout/adaptive.dart';
import 'package:gallery/pages/home.dart';
const homePeekDesktop = 210.0;
const homePeekMobile = 60.0;
class SplashPageAnimation extends InheritedWidget {
const SplashPageAnimation({
Key key,
@required this.isFinished,
@required Widget child,
}) : assert(child != null),
super(key: key, child: child);
final bool isFinished;
static SplashPageAnimation of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType();
}
@override
bool updateShouldNotify(SplashPageAnimation old) => true;
}
class SplashPage extends StatefulWidget {
const SplashPage({
Key key,
this.isAnimated = true,
@required this.child,
}) : super(key: key);
final bool isAnimated;
final Widget child;
@override
_SplashPageState createState() => _SplashPageState();
}
class _SplashPageState extends State<SplashPage>
with SingleTickerProviderStateMixin {
AnimationController _controller;
Timer _launchTimer;
int _effect;
final _random = Random();
// A map of the effect index to its duration. This duration is used to
// determine how long to display the splash animation at launch.
//
// If a new effect is added, this map should be updated.
final _effectDurations = {
1: 5,
2: 4,
3: 4,
4: 5,
5: 5,
6: 4,
7: 4,
8: 4,
9: 3,
10: 6,
};
bool get _isSplashVisible {
return _controller.status == AnimationStatus.completed ||
_controller.status == AnimationStatus.forward;
}
@override
void initState() {
super.initState();
// If the number of included effects changes, this number should be changed.
_effect = _random.nextInt(_effectDurations.length) + 1;
_controller = AnimationController(
duration: Duration(
milliseconds: splashPageAnimationDurationInMilliseconds,
),
value: 1,
vsync: this)
..addListener(() {
this.setState(() {});
});
if (widget.isAnimated) {
_launchTimer = Timer(
Duration(seconds: _effectDurations[_effect]),
() {
_controller.fling(velocity: -1);
},
);
} else {
_controller.value = 0;
}
}
@override
void dispose() {
_controller.dispose();
_launchTimer?.cancel();
_launchTimer = null;
super.dispose();
}
Animation<RelativeRect> _getPanelAnimation(
BuildContext context,
BoxConstraints constraints,
) {
final double height = constraints.biggest.height -
(isDisplayDesktop(context) ? homePeekDesktop : homePeekMobile);
return RelativeRectTween(
begin: const RelativeRect.fromLTRB(0, 0, 0, 0),
end: RelativeRect.fromLTRB(0, height, 0, 0),
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
}
@override
Widget build(BuildContext context) {
return NotificationListener<ToggleSplashNotification>(
onNotification: (_) {
_controller.forward();
return true;
},
child: SplashPageAnimation(
isFinished: _controller.status == AnimationStatus.dismissed,
child: LayoutBuilder(
builder: (context, constraints) {
final Animation<RelativeRect> animation =
_getPanelAnimation(context, constraints);
Widget frontLayer = widget.child;
if (_isSplashVisible) {
frontLayer = GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
_controller.reverse();
},
onVerticalDragEnd: (details) {
if (details.velocity.pixelsPerSecond.dy < -200) {
_controller.reverse();
}
},
child: IgnorePointer(child: frontLayer),
);
}
if (isDisplayDesktop(context)) {
frontLayer = Padding(
padding: const EdgeInsets.only(top: 136),
child: ClipRRect(
borderRadius: BorderRadius.vertical(
top: Radius.circular(40),
),
child: frontLayer,
),
);
}
return Stack(
children: [
_SplashBackLayer(
isSplashCollapsed: !_isSplashVisible,
effect: _effect,
onTap: () {
_controller.forward();
},
),
PositionedTransition(
rect: animation,
child: frontLayer,
),
],
);
},
),
),
);
}
}
class _SplashBackLayer extends StatelessWidget {
_SplashBackLayer({
Key key,
@required this.isSplashCollapsed,
this.effect,
this.onTap,
}) : super(key: key);
final bool isSplashCollapsed;
final int effect;
final GestureTapCallback onTap;
@override
Widget build(BuildContext context) {
var effectAsset = 'assets/splash_effects/splash_effect_$effect.gif';
final flutterLogo = Image.asset('assets/logo/flutter_logo.png');
Widget child;
if (isSplashCollapsed) {
child = isDisplayDesktop(context)
? Padding(
padding: const EdgeInsets.only(top: 50),
child: Align(
alignment: Alignment.topCenter,
child: GestureDetector(
onTap: onTap,
child: flutterLogo,
),
),
)
: null;
} else {
child = Stack(
children: [
Center(child: Image.asset(effectAsset)),
Center(child: flutterLogo),
],
);
}
return ExcludeSemantics(
child: Container(
color: Color(0xFF030303), // This is the background color of the gifs.
padding: EdgeInsets.only(
bottom: isDisplayDesktop(context) ? homePeekDesktop : homePeekMobile,
),
child: child,
),
);
}
}