• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2017 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5// Based on https://material.uplabs.com/posts/google-newsstand-navigation-pattern
6// See also: https://material-motion.github.io/material-motion/documentation/
7
8import 'dart:math' as math;
9
10import 'package:flutter/material.dart';
11import 'package:flutter/rendering.dart';
12
13import 'sections.dart';
14import 'widgets.dart';
15
16const Color _kAppBackgroundColor = Color(0xFF353662);
17const Duration _kScrollDuration = Duration(milliseconds: 400);
18const Curve _kScrollCurve = Curves.fastOutSlowIn;
19
20// This app's contents start out at _kHeadingMaxHeight and they function like
21// an appbar. Initially the appbar occupies most of the screen and its section
22// headings are laid out in a column. By the time its height has been
23// reduced to _kAppBarMidHeight, its layout is horizontal, only one section
24// heading is visible, and the section's list of details is visible below the
25// heading. The appbar's height can be reduced to no more than _kAppBarMinHeight.
26const double _kAppBarMinHeight = 90.0;
27const double _kAppBarMidHeight = 256.0;
28// The AppBar's max height depends on the screen, see _AnimationDemoHomeState._buildBody()
29
30// Initially occupies the same space as the status bar and gets smaller as
31// the primary scrollable scrolls upwards.
32// TODO(hansmuller): it would be worth adding something like this to the framework.
33class _RenderStatusBarPaddingSliver extends RenderSliver {
34  _RenderStatusBarPaddingSliver({
35    @required double maxHeight,
36    @required double scrollFactor,
37  }) : assert(maxHeight != null && maxHeight >= 0.0),
38       assert(scrollFactor != null && scrollFactor >= 1.0),
39       _maxHeight = maxHeight,
40       _scrollFactor = scrollFactor;
41
42  // The height of the status bar
43  double get maxHeight => _maxHeight;
44  double _maxHeight;
45  set maxHeight(double value) {
46    assert(maxHeight != null && maxHeight >= 0.0);
47    if (_maxHeight == value)
48      return;
49    _maxHeight = value;
50    markNeedsLayout();
51  }
52
53  // That rate at which this renderer's height shrinks when the scroll
54  // offset changes.
55  double get scrollFactor => _scrollFactor;
56  double _scrollFactor;
57  set scrollFactor(double value) {
58    assert(scrollFactor != null && scrollFactor >= 1.0);
59    if (_scrollFactor == value)
60      return;
61    _scrollFactor = value;
62    markNeedsLayout();
63  }
64
65  @override
66  void performLayout() {
67    final double height = (maxHeight - constraints.scrollOffset / scrollFactor).clamp(0.0, maxHeight);
68    geometry = SliverGeometry(
69      paintExtent: math.min(height, constraints.remainingPaintExtent),
70      scrollExtent: maxHeight,
71      maxPaintExtent: maxHeight,
72    );
73  }
74}
75
76class _StatusBarPaddingSliver extends SingleChildRenderObjectWidget {
77  const _StatusBarPaddingSliver({
78    Key key,
79    @required this.maxHeight,
80    this.scrollFactor = 5.0,
81  }) : assert(maxHeight != null && maxHeight >= 0.0),
82       assert(scrollFactor != null && scrollFactor >= 1.0),
83       super(key: key);
84
85  final double maxHeight;
86  final double scrollFactor;
87
88  @override
89  _RenderStatusBarPaddingSliver createRenderObject(BuildContext context) {
90    return _RenderStatusBarPaddingSliver(
91      maxHeight: maxHeight,
92      scrollFactor: scrollFactor,
93    );
94  }
95
96  @override
97  void updateRenderObject(BuildContext context, _RenderStatusBarPaddingSliver renderObject) {
98    renderObject
99      ..maxHeight = maxHeight
100      ..scrollFactor = scrollFactor;
101  }
102
103  @override
104  void debugFillProperties(DiagnosticPropertiesBuilder description) {
105    super.debugFillProperties(description);
106    description.add(DoubleProperty('maxHeight', maxHeight));
107    description.add(DoubleProperty('scrollFactor', scrollFactor));
108  }
109}
110
111class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
112  _SliverAppBarDelegate({
113    @required this.minHeight,
114    @required this.maxHeight,
115    @required this.child,
116  });
117
118  final double minHeight;
119  final double maxHeight;
120  final Widget child;
121
122  @override
123  double get minExtent => minHeight;
124  @override
125  double get maxExtent => math.max(maxHeight, minHeight);
126
127  @override
128  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
129    return SizedBox.expand(child: child);
130  }
131
132  @override
133  bool shouldRebuild(_SliverAppBarDelegate oldDelegate) {
134    return maxHeight != oldDelegate.maxHeight
135        || minHeight != oldDelegate.minHeight
136        || child != oldDelegate.child;
137  }
138
139  @override
140  String toString() => '_SliverAppBarDelegate';
141}
142
143// Arrange the section titles, indicators, and cards. The cards are only included when
144// the layout is transitioning between vertical and horizontal. Once the layout is
145// horizontal the cards are laid out by a PageView.
146//
147// The layout of the section cards, titles, and indicators is defined by the
148// two 0.0-1.0 "t" parameters, both of which are based on the layout's height:
149// - tColumnToRow
150//   0.0 when height is maxHeight and the layout is a column
151//   1.0 when the height is midHeight and the layout is a row
152// - tCollapsed
153//   0.0 when height is midHeight and the layout is a row
154//   1.0 when height is minHeight and the layout is a (still) row
155//
156// minHeight < midHeight < maxHeight
157//
158// The general approach here is to compute the column layout and row size
159// and position of each element and then interpolate between them using
160// tColumnToRow. Once tColumnToRow reaches 1.0, the layout changes are
161// defined by tCollapsed. As tCollapsed increases the titles spread out
162// until only one title is visible and the indicators cluster together
163// until they're all visible.
164class _AllSectionsLayout extends MultiChildLayoutDelegate {
165  _AllSectionsLayout({
166    this.translation,
167    this.tColumnToRow,
168    this.tCollapsed,
169    this.cardCount,
170    this.selectedIndex,
171  });
172
173  final Alignment translation;
174  final double tColumnToRow;
175  final double tCollapsed;
176  final int cardCount;
177  final double selectedIndex;
178
179  Rect _interpolateRect(Rect begin, Rect end) {
180    return Rect.lerp(begin, end, tColumnToRow);
181  }
182
183  Offset _interpolatePoint(Offset begin, Offset end) {
184    return Offset.lerp(begin, end, tColumnToRow);
185  }
186
187  @override
188  void performLayout(Size size) {
189    final double columnCardX = size.width / 5.0;
190    final double columnCardWidth = size.width - columnCardX;
191    final double columnCardHeight = size.height / cardCount;
192    final double rowCardWidth = size.width;
193    final Offset offset = translation.alongSize(size);
194    double columnCardY = 0.0;
195    double rowCardX = -(selectedIndex * rowCardWidth);
196
197    // When tCollapsed > 0 the titles spread apart
198    final double columnTitleX = size.width / 10.0;
199    final double rowTitleWidth = size.width * ((1 + tCollapsed) / 2.25);
200    double rowTitleX = (size.width - rowTitleWidth) / 2.0 - selectedIndex * rowTitleWidth;
201
202    // When tCollapsed > 0, the indicators move closer together
203    //final double rowIndicatorWidth = 48.0 + (1.0 - tCollapsed) * (rowTitleWidth - 48.0);
204    const double paddedSectionIndicatorWidth = kSectionIndicatorWidth + 8.0;
205    final double rowIndicatorWidth = paddedSectionIndicatorWidth +
206      (1.0 - tCollapsed) * (rowTitleWidth - paddedSectionIndicatorWidth);
207    double rowIndicatorX = (size.width - rowIndicatorWidth) / 2.0 - selectedIndex * rowIndicatorWidth;
208
209    // Compute the size and origin of each card, title, and indicator for the maxHeight
210    // "column" layout, and the midHeight "row" layout. The actual layout is just the
211    // interpolated value between the column and row layouts for t.
212    for (int index = 0; index < cardCount; index++) {
213
214      // Layout the card for index.
215      final Rect columnCardRect = Rect.fromLTWH(columnCardX, columnCardY, columnCardWidth, columnCardHeight);
216      final Rect rowCardRect = Rect.fromLTWH(rowCardX, 0.0, rowCardWidth, size.height);
217      final Rect cardRect = _interpolateRect(columnCardRect, rowCardRect).shift(offset);
218      final String cardId = 'card$index';
219      if (hasChild(cardId)) {
220        layoutChild(cardId, BoxConstraints.tight(cardRect.size));
221        positionChild(cardId, cardRect.topLeft);
222      }
223
224      // Layout the title for index.
225      final Size titleSize = layoutChild('title$index', BoxConstraints.loose(cardRect.size));
226      final double columnTitleY = columnCardRect.centerLeft.dy - titleSize.height / 2.0;
227      final double rowTitleY = rowCardRect.centerLeft.dy - titleSize.height / 2.0;
228      final double centeredRowTitleX = rowTitleX + (rowTitleWidth - titleSize.width) / 2.0;
229      final Offset columnTitleOrigin = Offset(columnTitleX, columnTitleY);
230      final Offset rowTitleOrigin = Offset(centeredRowTitleX, rowTitleY);
231      final Offset titleOrigin = _interpolatePoint(columnTitleOrigin, rowTitleOrigin);
232      positionChild('title$index', titleOrigin + offset);
233
234      // Layout the selection indicator for index.
235      final Size indicatorSize = layoutChild('indicator$index', BoxConstraints.loose(cardRect.size));
236      final double columnIndicatorX = cardRect.centerRight.dx - indicatorSize.width - 16.0;
237      final double columnIndicatorY = cardRect.bottomRight.dy - indicatorSize.height - 16.0;
238      final Offset columnIndicatorOrigin = Offset(columnIndicatorX, columnIndicatorY);
239      final Rect titleRect = Rect.fromPoints(titleOrigin, titleSize.bottomRight(titleOrigin));
240      final double centeredRowIndicatorX = rowIndicatorX + (rowIndicatorWidth - indicatorSize.width) / 2.0;
241      final double rowIndicatorY = titleRect.bottomCenter.dy + 16.0;
242      final Offset rowIndicatorOrigin = Offset(centeredRowIndicatorX, rowIndicatorY);
243      final Offset indicatorOrigin = _interpolatePoint(columnIndicatorOrigin, rowIndicatorOrigin);
244      positionChild('indicator$index', indicatorOrigin + offset);
245
246      columnCardY += columnCardHeight;
247      rowCardX += rowCardWidth;
248      rowTitleX += rowTitleWidth;
249      rowIndicatorX += rowIndicatorWidth;
250    }
251  }
252
253  @override
254  bool shouldRelayout(_AllSectionsLayout oldDelegate) {
255    return tColumnToRow != oldDelegate.tColumnToRow
256      || cardCount != oldDelegate.cardCount
257      || selectedIndex != oldDelegate.selectedIndex;
258  }
259}
260
261class _AllSectionsView extends AnimatedWidget {
262  _AllSectionsView({
263    Key key,
264    this.sectionIndex,
265    @required this.sections,
266    @required this.selectedIndex,
267    this.minHeight,
268    this.midHeight,
269    this.maxHeight,
270    this.sectionCards = const <Widget>[],
271  }) : assert(sections != null),
272       assert(sectionCards != null),
273       assert(sectionCards.length == sections.length),
274       assert(sectionIndex >= 0 && sectionIndex < sections.length),
275       assert(selectedIndex != null),
276       assert(selectedIndex.value >= 0.0 && selectedIndex.value < sections.length.toDouble()),
277       super(key: key, listenable: selectedIndex);
278
279  final int sectionIndex;
280  final List<Section> sections;
281  final ValueNotifier<double> selectedIndex;
282  final double minHeight;
283  final double midHeight;
284  final double maxHeight;
285  final List<Widget> sectionCards;
286
287  double _selectedIndexDelta(int index) {
288    return (index.toDouble() - selectedIndex.value).abs().clamp(0.0, 1.0);
289  }
290
291  Widget _build(BuildContext context, BoxConstraints constraints) {
292    final Size size = constraints.biggest;
293
294    // The layout's progress from from a column to a row. Its value is
295    // 0.0 when size.height equals the maxHeight, 1.0 when the size.height
296    // equals the midHeight.
297    final double tColumnToRow =
298      1.0 - ((size.height - midHeight) /
299             (maxHeight - midHeight)).clamp(0.0, 1.0);
300
301
302    // The layout's progress from from the midHeight row layout to
303    // a minHeight row layout. Its value is 0.0 when size.height equals
304    // midHeight and 1.0 when size.height equals minHeight.
305    final double tCollapsed =
306      1.0 - ((size.height - minHeight) /
307             (midHeight - minHeight)).clamp(0.0, 1.0);
308
309    double _indicatorOpacity(int index) {
310      return 1.0 - _selectedIndexDelta(index) * 0.5;
311    }
312
313    double _titleOpacity(int index) {
314      return 1.0 - _selectedIndexDelta(index) * tColumnToRow * 0.5;
315    }
316
317    double _titleScale(int index) {
318      return 1.0 - _selectedIndexDelta(index) * tColumnToRow * 0.15;
319    }
320
321    final List<Widget> children = List<Widget>.from(sectionCards);
322
323    for (int index = 0; index < sections.length; index++) {
324      final Section section = sections[index];
325      children.add(LayoutId(
326        id: 'title$index',
327        child: SectionTitle(
328          section: section,
329          scale: _titleScale(index),
330          opacity: _titleOpacity(index),
331        ),
332      ));
333    }
334
335    for (int index = 0; index < sections.length; index++) {
336      children.add(LayoutId(
337        id: 'indicator$index',
338        child: SectionIndicator(
339          opacity: _indicatorOpacity(index),
340        ),
341      ));
342    }
343
344    return CustomMultiChildLayout(
345      delegate: _AllSectionsLayout(
346        translation: Alignment((selectedIndex.value - sectionIndex) * 2.0 - 1.0, -1.0),
347        tColumnToRow: tColumnToRow,
348        tCollapsed: tCollapsed,
349        cardCount: sections.length,
350        selectedIndex: selectedIndex.value,
351      ),
352      children: children,
353    );
354  }
355
356  @override
357  Widget build(BuildContext context) {
358    return LayoutBuilder(builder: _build);
359  }
360}
361
362// Support snapping scrolls to the midScrollOffset: the point at which the
363// app bar's height is _kAppBarMidHeight and only one section heading is
364// visible.
365class _SnappingScrollPhysics extends ClampingScrollPhysics {
366  const _SnappingScrollPhysics({
367    ScrollPhysics parent,
368    @required this.midScrollOffset,
369  }) : assert(midScrollOffset != null),
370       super(parent: parent);
371
372  final double midScrollOffset;
373
374  @override
375  _SnappingScrollPhysics applyTo(ScrollPhysics ancestor) {
376    return _SnappingScrollPhysics(parent: buildParent(ancestor), midScrollOffset: midScrollOffset);
377  }
378
379  Simulation _toMidScrollOffsetSimulation(double offset, double dragVelocity) {
380    final double velocity = math.max(dragVelocity, minFlingVelocity);
381    return ScrollSpringSimulation(spring, offset, midScrollOffset, velocity, tolerance: tolerance);
382  }
383
384  Simulation _toZeroScrollOffsetSimulation(double offset, double dragVelocity) {
385    final double velocity = math.max(dragVelocity, minFlingVelocity);
386    return ScrollSpringSimulation(spring, offset, 0.0, velocity, tolerance: tolerance);
387  }
388
389  @override
390  Simulation createBallisticSimulation(ScrollMetrics position, double dragVelocity) {
391    final Simulation simulation = super.createBallisticSimulation(position, dragVelocity);
392    final double offset = position.pixels;
393
394    if (simulation != null) {
395      // The drag ended with sufficient velocity to trigger creating a simulation.
396      // If the simulation is headed up towards midScrollOffset but will not reach it,
397      // then snap it there. Similarly if the simulation is headed down past
398      // midScrollOffset but will not reach zero, then snap it to zero.
399      final double simulationEnd = simulation.x(double.infinity);
400      if (simulationEnd >= midScrollOffset)
401        return simulation;
402      if (dragVelocity > 0.0)
403        return _toMidScrollOffsetSimulation(offset, dragVelocity);
404      if (dragVelocity < 0.0)
405        return _toZeroScrollOffsetSimulation(offset, dragVelocity);
406    } else {
407      // The user ended the drag with little or no velocity. If they
408      // didn't leave the offset above midScrollOffset, then
409      // snap to midScrollOffset if they're more than halfway there,
410      // otherwise snap to zero.
411      final double snapThreshold = midScrollOffset / 2.0;
412      if (offset >= snapThreshold && offset < midScrollOffset)
413        return _toMidScrollOffsetSimulation(offset, dragVelocity);
414      if (offset > 0.0 && offset < snapThreshold)
415        return _toZeroScrollOffsetSimulation(offset, dragVelocity);
416    }
417    return simulation;
418  }
419}
420
421class AnimationDemoHome extends StatefulWidget {
422  const AnimationDemoHome({ Key key }) : super(key: key);
423
424  static const String routeName = '/animation';
425
426  @override
427  _AnimationDemoHomeState createState() => _AnimationDemoHomeState();
428}
429
430class _AnimationDemoHomeState extends State<AnimationDemoHome> {
431  final ScrollController _scrollController = ScrollController();
432  final PageController _headingPageController = PageController();
433  final PageController _detailsPageController = PageController();
434  ScrollPhysics _headingScrollPhysics = const NeverScrollableScrollPhysics();
435  ValueNotifier<double> selectedIndex = ValueNotifier<double>(0.0);
436
437  @override
438  Widget build(BuildContext context) {
439    return Scaffold(
440      backgroundColor: _kAppBackgroundColor,
441      body: Builder(
442        // Insert an element so that _buildBody can find the PrimaryScrollController.
443        builder: _buildBody,
444      ),
445    );
446  }
447
448  void _handleBackButton(double midScrollOffset) {
449    if (_scrollController.offset >= midScrollOffset)
450      _scrollController.animateTo(0.0, curve: _kScrollCurve, duration: _kScrollDuration);
451    else
452      Navigator.maybePop(context);
453  }
454
455  // Only enable paging for the heading when the user has scrolled to midScrollOffset.
456  // Paging is enabled/disabled by setting the heading's PageView scroll physics.
457  bool _handleScrollNotification(ScrollNotification notification, double midScrollOffset) {
458    if (notification.depth == 0 && notification is ScrollUpdateNotification) {
459      final ScrollPhysics physics = _scrollController.position.pixels >= midScrollOffset
460       ? const PageScrollPhysics()
461       : const NeverScrollableScrollPhysics();
462      if (physics != _headingScrollPhysics) {
463        setState(() {
464          _headingScrollPhysics = physics;
465        });
466      }
467    }
468    return false;
469  }
470
471  void _maybeScroll(double midScrollOffset, int pageIndex, double xOffset) {
472    if (_scrollController.offset < midScrollOffset) {
473      // Scroll the overall list to the point where only one section card shows.
474      // At the same time scroll the PageViews to the page at pageIndex.
475      _headingPageController.animateToPage(pageIndex, curve: _kScrollCurve, duration: _kScrollDuration);
476      _scrollController.animateTo(midScrollOffset, curve: _kScrollCurve, duration: _kScrollDuration);
477    } else {
478      // One one section card is showing: scroll one page forward or back.
479      final double centerX = _headingPageController.position.viewportDimension / 2.0;
480      final int newPageIndex = xOffset > centerX ? pageIndex + 1 : pageIndex - 1;
481      _headingPageController.animateToPage(newPageIndex, curve: _kScrollCurve, duration: _kScrollDuration);
482    }
483  }
484
485  bool _handlePageNotification(ScrollNotification notification, PageController leader, PageController follower) {
486    if (notification.depth == 0 && notification is ScrollUpdateNotification) {
487      selectedIndex.value = leader.page;
488      if (follower.page != leader.page)
489        follower.position.jumpToWithoutSettling(leader.position.pixels); // ignore: deprecated_member_use
490    }
491    return false;
492  }
493
494  Iterable<Widget> _detailItemsFor(Section section) {
495    final Iterable<Widget> detailItems = section.details.map<Widget>((SectionDetail detail) {
496      return SectionDetailView(detail: detail);
497    });
498    return ListTile.divideTiles(context: context, tiles: detailItems);
499  }
500
501  Iterable<Widget> _allHeadingItems(double maxHeight, double midScrollOffset) {
502    final List<Widget> sectionCards = <Widget>[];
503    for (int index = 0; index < allSections.length; index++) {
504      sectionCards.add(LayoutId(
505        id: 'card$index',
506        child: GestureDetector(
507          behavior: HitTestBehavior.opaque,
508          child: SectionCard(section: allSections[index]),
509          onTapUp: (TapUpDetails details) {
510            final double xOffset = details.globalPosition.dx;
511            setState(() {
512              _maybeScroll(midScrollOffset, index, xOffset);
513            });
514          },
515        ),
516      ));
517    }
518
519    final List<Widget> headings = <Widget>[];
520    for (int index = 0; index < allSections.length; index++) {
521      headings.add(Container(
522          color: _kAppBackgroundColor,
523          child: ClipRect(
524            child: _AllSectionsView(
525              sectionIndex: index,
526              sections: allSections,
527              selectedIndex: selectedIndex,
528              minHeight: _kAppBarMinHeight,
529              midHeight: _kAppBarMidHeight,
530              maxHeight: maxHeight,
531              sectionCards: sectionCards,
532            ),
533          ),
534        )
535      );
536    }
537    return headings;
538  }
539
540  Widget _buildBody(BuildContext context) {
541    final MediaQueryData mediaQueryData = MediaQuery.of(context);
542    final double statusBarHeight = mediaQueryData.padding.top;
543    final double screenHeight = mediaQueryData.size.height;
544    final double appBarMaxHeight = screenHeight - statusBarHeight;
545
546    // The scroll offset that reveals the appBarMidHeight appbar.
547    final double appBarMidScrollOffset = statusBarHeight + appBarMaxHeight - _kAppBarMidHeight;
548
549    return SizedBox.expand(
550      child: Stack(
551        children: <Widget>[
552          NotificationListener<ScrollNotification>(
553            onNotification: (ScrollNotification notification) {
554              return _handleScrollNotification(notification, appBarMidScrollOffset);
555            },
556            child: CustomScrollView(
557              controller: _scrollController,
558              physics: _SnappingScrollPhysics(midScrollOffset: appBarMidScrollOffset),
559              slivers: <Widget>[
560                // Start out below the status bar, gradually move to the top of the screen.
561                _StatusBarPaddingSliver(
562                  maxHeight: statusBarHeight,
563                  scrollFactor: 7.0,
564                ),
565                // Section Headings
566                SliverPersistentHeader(
567                  pinned: true,
568                  delegate: _SliverAppBarDelegate(
569                    minHeight: _kAppBarMinHeight,
570                    maxHeight: appBarMaxHeight,
571                    child: NotificationListener<ScrollNotification>(
572                      onNotification: (ScrollNotification notification) {
573                        return _handlePageNotification(notification, _headingPageController, _detailsPageController);
574                      },
575                      child: PageView(
576                        physics: _headingScrollPhysics,
577                        controller: _headingPageController,
578                        children: _allHeadingItems(appBarMaxHeight, appBarMidScrollOffset),
579                      ),
580                    ),
581                  ),
582                ),
583                // Details
584                SliverToBoxAdapter(
585                  child: SizedBox(
586                    height: 610.0,
587                    child: NotificationListener<ScrollNotification>(
588                      onNotification: (ScrollNotification notification) {
589                        return _handlePageNotification(notification, _detailsPageController, _headingPageController);
590                      },
591                      child: PageView(
592                        controller: _detailsPageController,
593                        children: allSections.map<Widget>((Section section) {
594                          return Column(
595                            crossAxisAlignment: CrossAxisAlignment.stretch,
596                            children: _detailItemsFor(section).toList(),
597                          );
598                        }).toList(),
599                      ),
600                    ),
601                  ),
602                ),
603              ],
604            ),
605          ),
606          Positioned(
607            top: statusBarHeight,
608            left: 0.0,
609            child: IconTheme(
610              data: const IconThemeData(color: Colors.white),
611              child: SafeArea(
612                top: false,
613                bottom: false,
614                child: IconButton(
615                  icon: const BackButtonIcon(),
616                  tooltip: 'Back',
617                  onPressed: () {
618                    _handleBackButton(appBarMidScrollOffset);
619                  },
620                ),
621              ),
622            ),
623          ),
624        ],
625      ),
626    );
627  }
628}
629