diff --git a/lib/common/widgets/image/custom_grid_view.dart b/lib/common/widgets/image/custom_grid_view.dart index 2a920712d..a277e79df 100644 --- a/lib/common/widgets/image/custom_grid_view.dart +++ b/lib/common/widgets/image/custom_grid_view.dart @@ -23,6 +23,7 @@ import 'package:PiliPlus/common/widgets/custom_layout.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/models/common/badge_type.dart'; import 'package:PiliPlus/models/common/image_preview_type.dart'; +import 'package:PiliPlus/utils/context_ext.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; @@ -64,7 +65,7 @@ class CustomGridView extends StatelessWidget { required this.picArr, this.onViewImage, this.onDismissed, - this.callback, + this.fullScreen = false, }); final double maxWidth; @@ -72,29 +73,36 @@ class CustomGridView extends StatelessWidget { final List picArr; final VoidCallback? onViewImage; final ValueChanged? onDismissed; - final Function(List, int)? callback; + final bool fullScreen; - void onTap(int index) { - if (callback != null) { - callback!(picArr.map((item) => item.url).toList(), index); + static bool horizontalPreview = Pref.horizontalPreview; + + void onTap(BuildContext context, int index) { + final imgList = picArr.map( + (item) { + bool isLive = item.isLivePhoto; + return SourceModel( + sourceType: isLive ? SourceType.livePhoto : SourceType.networkImage, + url: item.url, + liveUrl: isLive ? item.liveUrl : null, + width: isLive ? item.width.toInt() : null, + height: isLive ? item.height.toInt() : null, + ); + }, + ).toList(); + onViewImage?.call(); + if (horizontalPreview && + !fullScreen && + !context.mediaQuerySize.isPortrait) { + PageUtils.onHorizontalPreview( + context, + imgList, + index, + ); } else { - onViewImage?.call(); PageUtils.imageView( initialPage: index, - imgList: picArr.map( - (item) { - bool isLive = item.isLivePhoto; - return SourceModel( - sourceType: isLive - ? SourceType.livePhoto - : SourceType.networkImage, - url: item.url, - liveUrl: isLive ? item.liveUrl : null, - width: isLive ? item.width.toInt() : null, - height: isLive ? item.height.toInt() : null, - ); - }, - ).toList(), + imgList: imgList, onDismissed: onDismissed, ); } @@ -195,7 +203,7 @@ class CustomGridView extends StatelessWidget { child: Hero( tag: item.url, child: GestureDetector( - onTap: () => onTap(index), + onTap: () => onTap(context, index), child: Stack( clipBehavior: Clip.none, alignment: Alignment.center, diff --git a/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart b/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart index f7e9b294e..ab886f114 100644 --- a/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart +++ b/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart @@ -246,7 +246,7 @@ class _InteractiveviewerGalleryState extends State children: [ InteractiveViewerBoundary( controller: _transformationController, - boundaryWidth: MediaQuery.sizeOf(context).width, + boundaryWidth: MediaQuery.widthOf(context), onScaleChanged: _onScaleChanged, onLeftBoundaryHit: _onLeftBoundaryHit, onRightBoundaryHit: _onRightBoundaryHit, diff --git a/lib/common/widgets/keep_alive_wrapper.dart b/lib/common/widgets/keep_alive_wrapper.dart index c10508423..a7e76076e 100644 --- a/lib/common/widgets/keep_alive_wrapper.dart +++ b/lib/common/widgets/keep_alive_wrapper.dart @@ -22,6 +22,14 @@ class _KeepAliveWrapperState extends State return widget.builder(context); } + @override + void didUpdateWidget(KeepAliveWrapper oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.wantKeepAlive != widget.wantKeepAlive) { + updateKeepAlive(); + } + } + @override bool get wantKeepAlive => widget.wantKeepAlive; } diff --git a/lib/common/widgets/marquee.dart b/lib/common/widgets/marquee.dart index 6c86f02d0..feab4917b 100644 --- a/lib/common/widgets/marquee.dart +++ b/lib/common/widgets/marquee.dart @@ -142,7 +142,9 @@ abstract class MarqueeRender extends RenderBox if (value._ticker != null) { value._ticker!.absorbTicker(_ticker._ticker!); } else { - value.createTicker(_onTick); + value + ..createTicker(_onTick) + ..initStart(); } } _ticker.cancel(); @@ -223,7 +225,9 @@ abstract class MarqueeRender extends RenderBox if (_distance > 0) { updateSize(); - _ticker.createTicker(_onTick); + _ticker + ..createTicker(_onTick) + ..initStart(); } else { _ticker.cancel(); } @@ -394,13 +398,29 @@ class _MarqueeSimulation extends Simulation { ); } -class ContextSingleTicker { +class ContextSingleTicker implements TickerProvider { Ticker? _ticker; BuildContext context; + final bool autoStart; - ContextSingleTicker(this.context); + ContextSingleTicker(this.context, {this.autoStart = true}); - void createTicker(TickerCallback onTick) { + void initStart() { + if (autoStart) { + _ticker?.start(); + } + } + + void startIfNeeded() { + if (_ticker case final ticker?) { + if (!ticker.isActive) { + ticker.start(); + } + } + } + + @override + Ticker createTicker(TickerCallback onTick) { assert(() { if (_ticker == null) { return true; @@ -422,10 +442,11 @@ class ContextSingleTicker { _ticker = Ticker( onTick, debugLabel: kDebugMode ? 'created by ${describeIdentity(this)}' : null, - )..start(); + ); _tickerModeNotifier = TickerMode.getNotifier(context) ..addListener(updateTicker); updateTicker(); // Sets _ticker.mute correctly. + return _ticker!; } void reset() { diff --git a/lib/common/widgets/scaffold/bottom_sheet.dart b/lib/common/widgets/scaffold/bottom_sheet.dart new file mode 100644 index 000000000..37a600f94 --- /dev/null +++ b/lib/common/widgets/scaffold/bottom_sheet.dart @@ -0,0 +1,1557 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// @docImport 'dart:ui'; +library; + +import 'dart:math' as math; + +import 'package:PiliPlus/common/widgets/scaffold/scaffold.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' + hide Scaffold, ScaffoldState, PersistentBottomSheetController; +import 'package:flutter/rendering.dart'; + +const Duration _bottomSheetEnterDuration = Duration(milliseconds: 250); +const Duration _bottomSheetExitDuration = Duration(milliseconds: 200); +const Curve _modalBottomSheetCurve = Easing.legacyDecelerate; +const double _minFlingVelocity = 700.0; +const double _closeProgressThreshold = 0.5; +const double _defaultScrollControlDisabledMaxHeightRatio = 9.0 / 16.0; + +/// A callback for when the user begins dragging the bottom sheet. +/// +/// Used by [BottomSheet.onDragStart]. +typedef BottomSheetDragStartHandler = void Function(DragStartDetails details); + +/// A callback for when the user stops dragging the bottom sheet. +/// +/// Used by [BottomSheet.onDragEnd]. +typedef BottomSheetDragEndHandler = + void Function(DragEndDetails details, {required bool isClosing}); + +/// A Material Design bottom sheet. +/// +/// There are two kinds of bottom sheets in Material Design: +/// +/// * _Persistent_. A persistent bottom sheet shows information that +/// supplements the primary content of the app. A persistent bottom sheet +/// remains visible even when the user interacts with other parts of the app. +/// Persistent bottom sheets can be created and displayed with the +/// [ScaffoldState.showBottomSheet] function or by specifying the +/// [Scaffold.bottomSheet] constructor parameter. +/// +/// * _Modal_. A modal bottom sheet is an alternative to a menu or a dialog and +/// prevents the user from interacting with the rest of the app. Modal bottom +/// sheets can be created and displayed with the [showModalBottomSheet] +/// function. +/// +/// The [BottomSheet] widget itself is rarely used directly. Instead, prefer to +/// create a persistent bottom sheet with [ScaffoldState.showBottomSheet] or +/// [Scaffold.bottomSheet], and a modal bottom sheet with [showModalBottomSheet]. +/// +/// See also: +/// +/// * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing +/// non-modal "persistent" bottom sheets. +/// * [showModalBottomSheet], which can be used to display a modal bottom +/// sheet. +/// * [BottomSheetThemeData], which can be used to customize the default +/// bottom sheet property values. +/// * The Material 2 spec at . +/// * The Material 3 spec at . +class BottomSheet extends StatefulWidget { + /// Creates a bottom sheet. + /// + /// Typically, bottom sheets are created implicitly by + /// [ScaffoldState.showBottomSheet], for persistent bottom sheets, or by + /// [showModalBottomSheet], for modal bottom sheets. + const BottomSheet({ + super.key, + this.animationController, + this.enableDrag = true, + this.showDragHandle, + this.dragHandleColor, + this.dragHandleSize, + this.onDragStart, + this.onDragEnd, + this.backgroundColor, + this.shadowColor, + this.elevation, + this.shape, + this.clipBehavior, + this.constraints, + required this.onClosing, + required this.builder, + }) : assert(elevation == null || elevation >= 0.0); + + /// The animation controller that controls the bottom sheet's entrance and + /// exit animations. + /// + /// The BottomSheet widget will manipulate the position of this animation, it + /// is not just a passive observer. + final AnimationController? animationController; + + /// Called when the bottom sheet begins to close. + /// + /// A bottom sheet might be prevented from closing (e.g., by user + /// interaction) even after this callback is called. For this reason, this + /// callback might be call multiple times for a given bottom sheet. + final VoidCallback onClosing; + + /// A builder for the contents of the sheet. + /// + /// The bottom sheet will wrap the widget produced by this builder in a + /// [Material] widget. + final WidgetBuilder builder; + + /// If true, the bottom sheet can be dragged up and down and dismissed by + /// swiping downwards. + /// + /// If [showDragHandle] is true, this only applies to the content below the drag handle, + /// because the drag handle is always draggable. + /// + /// Default is true. + /// + /// If this is true, the [animationController] must not be null. + /// Use [BottomSheet.createAnimationController] to create one, or provide + /// another AnimationController. + final bool enableDrag; + + /// Specifies whether a drag handle is shown. + /// + /// The drag handle appears at the top of the bottom sheet. The default color is + /// [ColorScheme.onSurfaceVariant] with an opacity of 0.4 and can be customized + /// using [dragHandleColor]. The default size is `Size(32,4)` and can be customized + /// with [dragHandleSize]. + /// + /// If null, then the value of [BottomSheetThemeData.showDragHandle] is used. If + /// that is also null, defaults to false. + /// + /// If this is true, the [animationController] must not be null. + /// Use [BottomSheet.createAnimationController] to create one, or provide + /// another AnimationController. + final bool? showDragHandle; + + /// The bottom sheet drag handle's color. + /// + /// Defaults to [BottomSheetThemeData.dragHandleColor]. + /// If that is also null, defaults to [ColorScheme.onSurfaceVariant]. + final Color? dragHandleColor; + + /// Defaults to [BottomSheetThemeData.dragHandleSize]. + /// If that is also null, defaults to Size(32, 4). + final Size? dragHandleSize; + + /// Called when the user begins dragging the bottom sheet vertically, if + /// [enableDrag] is true. + /// + /// Would typically be used to change the bottom sheet animation curve so + /// that it tracks the user's finger accurately. + final BottomSheetDragStartHandler? onDragStart; + + /// Called when the user stops dragging the bottom sheet, if [enableDrag] + /// is true. + /// + /// Would typically be used to reset the bottom sheet animation curve, so + /// that it animates non-linearly. Called before [onClosing] if the bottom + /// sheet is closing. + final BottomSheetDragEndHandler? onDragEnd; + + /// The bottom sheet's background color. + /// + /// Defines the bottom sheet's [Material.color]. + /// + /// Defaults to null and falls back to [Material]'s default. + final Color? backgroundColor; + + /// The color of the shadow below the sheet. + /// + /// If this property is null, then [BottomSheetThemeData.shadowColor] of + /// [ThemeData.bottomSheetTheme] is used. If that is also null, the default value + /// is transparent. + /// + /// See also: + /// + /// * [elevation], which defines the size of the shadow below the sheet. + /// * [shape], which defines the shape of the sheet and its shadow. + final Color? shadowColor; + + /// The z-coordinate at which to place this material relative to its parent. + /// + /// This controls the size of the shadow below the material. + /// + /// Defaults to 0. The value is non-negative. + final double? elevation; + + /// The shape of the bottom sheet. + /// + /// Defines the bottom sheet's [Material.shape]. + /// + /// Defaults to null and falls back to [Material]'s default. + final ShapeBorder? shape; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defines the bottom sheet's [Material.clipBehavior]. + /// + /// Use this property to enable clipping of content when the bottom sheet has + /// a custom [shape] and the content can extend past this shape. For example, + /// a bottom sheet with rounded corners and an edge-to-edge [Image] at the + /// top. + /// + /// If this property is null then [BottomSheetThemeData.clipBehavior] of + /// [ThemeData.bottomSheetTheme] is used. If that's null then the behavior + /// will be [Clip.none]. + final Clip? clipBehavior; + + /// Defines minimum and maximum sizes for a [BottomSheet]. + /// + /// If null, then the ambient [ThemeData.bottomSheetTheme]'s + /// [BottomSheetThemeData.constraints] will be used. If that + /// is null and [ThemeData.useMaterial3] is true, then the bottom sheet + /// will have a max width of 640dp. If [ThemeData.useMaterial3] is false, then + /// the bottom sheet's size will be constrained by its parent + /// (usually a [Scaffold]). In this case, consider limiting the width by + /// setting smaller constraints for large screens. + /// + /// If constraints are specified (either in this property or in the + /// theme), the bottom sheet will be aligned to the bottom-center of + /// the available space. Otherwise, no alignment is applied. + final BoxConstraints? constraints; + + @override + State createState() => _BottomSheetState(); + + /// Creates an [AnimationController] suitable for a + /// [BottomSheet.animationController]. + /// + /// This API is available as a convenience for a Material compliant bottom sheet + /// animation. If alternative animation durations are required, a different + /// animation controller could be provided. + static AnimationController createAnimationController( + TickerProvider vsync, { + AnimationStyle? sheetAnimationStyle, + }) { + return AnimationController( + duration: sheetAnimationStyle?.duration ?? _bottomSheetEnterDuration, + reverseDuration: + sheetAnimationStyle?.reverseDuration ?? _bottomSheetExitDuration, + debugLabel: 'BottomSheet', + vsync: vsync, + ); + } +} + +class _BottomSheetState extends State { + final GlobalKey _childKey = GlobalKey(debugLabel: 'BottomSheet child'); + + double get _childHeight { + final RenderBox renderBox = + _childKey.currentContext!.findRenderObject()! as RenderBox; + return renderBox.size.height; + } + + bool get _dismissUnderway => + widget.animationController!.status == AnimationStatus.reverse; + + Set dragHandleStates = {}; + + void _handleDragStart(DragStartDetails details) { + setState(() { + dragHandleStates.add(WidgetState.dragged); + }); + widget.onDragStart?.call(details); + } + + void _handleDragUpdate(DragUpdateDetails details) { + assert( + (widget.enableDrag || (widget.showDragHandle ?? false)) && + widget.animationController != null, + "'BottomSheet.animationController' cannot be null when 'BottomSheet.enableDrag' or 'BottomSheet.showDragHandle' is true. " + "Use 'BottomSheet.createAnimationController' to create one, or provide another AnimationController.", + ); + if (_dismissUnderway) { + return; + } + widget.animationController!.value -= details.primaryDelta! / _childHeight; + } + + void _handleDragEnd(DragEndDetails details) { + assert( + (widget.enableDrag || (widget.showDragHandle ?? false)) && + widget.animationController != null, + "'BottomSheet.animationController' cannot be null when 'BottomSheet.enableDrag' or 'BottomSheet.showDragHandle' is true. " + "Use 'BottomSheet.createAnimationController' to create one, or provide another AnimationController.", + ); + if (_dismissUnderway) { + return; + } + setState(() { + dragHandleStates.remove(WidgetState.dragged); + }); + bool isClosing = false; + if (details.velocity.pixelsPerSecond.dy > _minFlingVelocity) { + final double flingVelocity = + -details.velocity.pixelsPerSecond.dy / _childHeight; + if (widget.animationController!.value > 0.0) { + widget.animationController!.fling(velocity: flingVelocity); + } + if (flingVelocity < 0.0) { + isClosing = true; + } + } else if (widget.animationController!.value < _closeProgressThreshold) { + if (widget.animationController!.value > 0.0) { + widget.animationController!.fling(velocity: -1.0); + } + isClosing = true; + } else { + widget.animationController!.forward(); + } + + widget.onDragEnd?.call(details, isClosing: isClosing); + + if (isClosing) { + widget.onClosing(); + } + } + + bool extentChanged(DraggableScrollableNotification notification) { + if (notification.extent == notification.minExtent && + notification.shouldCloseOnMinExtent) { + widget.onClosing(); + } + return false; + } + + void _handleDragHandleHover(bool hovering) { + if (hovering != dragHandleStates.contains(WidgetState.hovered)) { + setState(() { + if (hovering) { + dragHandleStates.add(WidgetState.hovered); + } else { + dragHandleStates.remove(WidgetState.hovered); + } + }); + } + } + + @override + Widget build(BuildContext context) { + final BottomSheetThemeData bottomSheetTheme = Theme.of( + context, + ).bottomSheetTheme; + final bool useMaterial3 = Theme.of(context).useMaterial3; + final BottomSheetThemeData defaults = useMaterial3 + ? _BottomSheetDefaultsM3(context) + : const BottomSheetThemeData(); + final BoxConstraints? constraints = + widget.constraints ?? + bottomSheetTheme.constraints ?? + defaults.constraints; + final Color? color = + widget.backgroundColor ?? + bottomSheetTheme.backgroundColor ?? + defaults.backgroundColor; + final Color? surfaceTintColor = + bottomSheetTheme.surfaceTintColor ?? defaults.surfaceTintColor; + final Color? shadowColor = + widget.shadowColor ?? + bottomSheetTheme.shadowColor ?? + defaults.shadowColor; + final double elevation = + widget.elevation ?? + bottomSheetTheme.elevation ?? + defaults.elevation ?? + 0; + final ShapeBorder? shape = + widget.shape ?? bottomSheetTheme.shape ?? defaults.shape; + final Clip clipBehavior = + widget.clipBehavior ?? bottomSheetTheme.clipBehavior ?? Clip.none; + final bool showDragHandle = + widget.showDragHandle ?? + (widget.enableDrag && (bottomSheetTheme.showDragHandle ?? false)); + + Widget? dragHandle; + if (showDragHandle) { + dragHandle = _DragHandle( + onSemanticsTap: widget.onClosing, + handleHover: _handleDragHandleHover, + states: dragHandleStates, + dragHandleColor: widget.dragHandleColor, + dragHandleSize: widget.dragHandleSize, + ); + // Only add [_BottomSheetGestureDetector] to the drag handle when the rest of the + // bottom sheet is not draggable. If the whole bottom sheet is draggable, + // no need to add it. + if (!widget.enableDrag) { + dragHandle = _BottomSheetGestureDetector( + onVerticalDragStart: _handleDragStart, + onVerticalDragUpdate: _handleDragUpdate, + onVerticalDragEnd: _handleDragEnd, + child: dragHandle, + ); + } + } + + Widget bottomSheet = Material( + key: _childKey, + color: color, + elevation: elevation, + surfaceTintColor: surfaceTintColor, + shadowColor: shadowColor, + shape: shape, + clipBehavior: clipBehavior, + child: NotificationListener( + onNotification: extentChanged, + child: !showDragHandle + ? widget.builder(context) + : Stack( + alignment: Alignment.topCenter, + children: [ + dragHandle!, + Padding( + padding: const EdgeInsets.only( + top: kMinInteractiveDimension, + ), + child: widget.builder(context), + ), + ], + ), + ), + ); + + if (constraints != null) { + bottomSheet = Align( + alignment: Alignment.bottomCenter, + heightFactor: 1.0, + child: ConstrainedBox(constraints: constraints, child: bottomSheet), + ); + } + + return !widget.enableDrag + ? bottomSheet + : _BottomSheetGestureDetector( + onVerticalDragStart: _handleDragStart, + onVerticalDragUpdate: _handleDragUpdate, + onVerticalDragEnd: _handleDragEnd, + child: bottomSheet, + ); + } +} + +// PERSISTENT BOTTOM SHEETS + +// See scaffold.dart + +class _DragHandle extends StatelessWidget { + const _DragHandle({ + required this.onSemanticsTap, + required this.handleHover, + required this.states, + this.dragHandleColor, + this.dragHandleSize, + }); + + final VoidCallback? onSemanticsTap; + final ValueChanged handleHover; + final Set states; + final Color? dragHandleColor; + final Size? dragHandleSize; + + @override + Widget build(BuildContext context) { + final BottomSheetThemeData bottomSheetTheme = Theme.of( + context, + ).bottomSheetTheme; + final BottomSheetThemeData m3Defaults = _BottomSheetDefaultsM3(context); + final Size handleSize = + dragHandleSize ?? + bottomSheetTheme.dragHandleSize ?? + m3Defaults.dragHandleSize!; + + return MouseRegion( + onEnter: (PointerEnterEvent event) => handleHover(true), + onExit: (PointerExitEvent event) => handleHover(false), + child: Semantics( + label: MaterialLocalizations.of(context).modalBarrierDismissLabel, + container: true, + button: true, + onTap: onSemanticsTap, + child: SizedBox( + width: math.max(handleSize.width, kMinInteractiveDimension), + height: math.max(handleSize.height, kMinInteractiveDimension), + child: Center( + child: Container( + height: handleSize.height, + width: handleSize.width, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(handleSize.height / 2), + color: + WidgetStateProperty.resolveAs( + dragHandleColor, + states, + ) ?? + WidgetStateProperty.resolveAs( + bottomSheetTheme.dragHandleColor, + states, + ) ?? + m3Defaults.dragHandleColor, + ), + ), + ), + ), + ), + ); + } +} + +class _BottomSheetLayoutWithSizeListener extends SingleChildRenderObjectWidget { + const _BottomSheetLayoutWithSizeListener({ + required this.onChildSizeChanged, + required this.animationValue, + required this.isScrollControlled, + required this.scrollControlDisabledMaxHeightRatio, + super.child, + }); + + final ValueChanged onChildSizeChanged; + final double animationValue; + final bool isScrollControlled; + final double scrollControlDisabledMaxHeightRatio; + + @override + _RenderBottomSheetLayoutWithSizeListener createRenderObject( + BuildContext context, + ) { + return _RenderBottomSheetLayoutWithSizeListener( + onChildSizeChanged: onChildSizeChanged, + animationValue: animationValue, + isScrollControlled: isScrollControlled, + scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderBottomSheetLayoutWithSizeListener renderObject, + ) { + renderObject.onChildSizeChanged = onChildSizeChanged; + renderObject.animationValue = animationValue; + renderObject.isScrollControlled = isScrollControlled; + renderObject.scrollControlDisabledMaxHeightRatio = + scrollControlDisabledMaxHeightRatio; + } +} + +class _RenderBottomSheetLayoutWithSizeListener extends RenderShiftedBox { + _RenderBottomSheetLayoutWithSizeListener({ + RenderBox? child, + required ValueChanged onChildSizeChanged, + required double animationValue, + required bool isScrollControlled, + required double scrollControlDisabledMaxHeightRatio, + }) : _onChildSizeChanged = onChildSizeChanged, + _animationValue = animationValue, + _isScrollControlled = isScrollControlled, + _scrollControlDisabledMaxHeightRatio = + scrollControlDisabledMaxHeightRatio, + super(child); + + Size _lastSize = Size.zero; + + ValueChanged get onChildSizeChanged => _onChildSizeChanged; + ValueChanged _onChildSizeChanged; + set onChildSizeChanged(ValueChanged newCallback) { + if (_onChildSizeChanged == newCallback) { + return; + } + + _onChildSizeChanged = newCallback; + markNeedsLayout(); + } + + double get animationValue => _animationValue; + double _animationValue; + set animationValue(double newValue) { + if (_animationValue == newValue) { + return; + } + + _animationValue = newValue; + markNeedsLayout(); + } + + bool get isScrollControlled => _isScrollControlled; + bool _isScrollControlled; + set isScrollControlled(bool newValue) { + if (_isScrollControlled == newValue) { + return; + } + + _isScrollControlled = newValue; + markNeedsLayout(); + } + + double get scrollControlDisabledMaxHeightRatio => + _scrollControlDisabledMaxHeightRatio; + double _scrollControlDisabledMaxHeightRatio; + set scrollControlDisabledMaxHeightRatio(double newValue) { + if (_scrollControlDisabledMaxHeightRatio == newValue) { + return; + } + + _scrollControlDisabledMaxHeightRatio = newValue; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) => 0.0; + + @override + double computeMaxIntrinsicWidth(double height) => 0.0; + + @override + double computeMinIntrinsicHeight(double width) => 0.0; + + @override + double computeMaxIntrinsicHeight(double width) => 0.0; + + @override + Size computeDryLayout(BoxConstraints constraints) => constraints.biggest; + + @override + double? computeDryBaseline( + covariant BoxConstraints constraints, + TextBaseline baseline, + ) { + final RenderBox? child = this.child; + if (child == null) { + return null; + } + final BoxConstraints childConstraints = _getConstraintsForChild( + constraints, + ); + final double? result = child.getDryBaseline(childConstraints, baseline); + if (result == null) { + return null; + } + final Size childSize = childConstraints.isTight + ? childConstraints.smallest + : child.getDryLayout(childConstraints); + return result + _getPositionForChild(constraints.biggest, childSize).dy; + } + + BoxConstraints _getConstraintsForChild(BoxConstraints constraints) { + return BoxConstraints( + minWidth: constraints.maxWidth, + maxWidth: constraints.maxWidth, + maxHeight: isScrollControlled + ? constraints.maxHeight + : constraints.maxHeight * scrollControlDisabledMaxHeightRatio, + ); + } + + Offset _getPositionForChild(Size size, Size childSize) { + return Offset(0.0, size.height - childSize.height * animationValue); + } + + @override + void performLayout() { + size = constraints.biggest; + final RenderBox? child = this.child; + if (child == null) { + return; + } + + final BoxConstraints childConstraints = _getConstraintsForChild( + constraints, + ); + assert(childConstraints.debugAssertIsValid(isAppliedConstraint: true)); + child.layout(childConstraints, parentUsesSize: !childConstraints.isTight); + final BoxParentData childParentData = child.parentData! as BoxParentData; + final Size childSize = childConstraints.isTight + ? childConstraints.smallest + : child.size; + childParentData.offset = _getPositionForChild(size, childSize); + + if (_lastSize != childSize) { + _lastSize = childSize; + _onChildSizeChanged.call(_lastSize); + } + } +} + +class _ModalBottomSheet extends StatefulWidget { + const _ModalBottomSheet({ + super.key, + required this.route, + this.backgroundColor, + this.elevation, + this.shape, + this.clipBehavior, + this.constraints, + this.isScrollControlled = false, + this.scrollControlDisabledMaxHeightRatio = + _defaultScrollControlDisabledMaxHeightRatio, + this.enableDrag = true, + this.showDragHandle = false, + }); + + final ModalBottomSheetRoute route; + final bool isScrollControlled; + final double scrollControlDisabledMaxHeightRatio; + final Color? backgroundColor; + final double? elevation; + final ShapeBorder? shape; + final Clip? clipBehavior; + final BoxConstraints? constraints; + final bool enableDrag; + final bool showDragHandle; + + @override + _ModalBottomSheetState createState() => _ModalBottomSheetState(); +} + +class _ModalBottomSheetState extends State<_ModalBottomSheet> { + ParametricCurve animationCurve = _modalBottomSheetCurve; + + String _getRouteLabel(MaterialLocalizations localizations) { + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return ''; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return localizations.dialogLabel; + } + } + + EdgeInsets _getNewClipDetails(Size topLayerSize) { + return EdgeInsets.fromLTRB(0, 0, 0, topLayerSize.height); + } + + void handleDragStart(DragStartDetails details) { + // Allow the bottom sheet to track the user's finger accurately. + animationCurve = Curves.linear; + } + + void handleDragEnd(DragEndDetails details, {bool? isClosing}) { + // Allow the bottom sheet to animate smoothly from its current position. + animationCurve = Split( + widget.route.animation!.value, + endCurve: _modalBottomSheetCurve, + ); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + assert(debugCheckHasMaterialLocalizations(context)); + final MaterialLocalizations localizations = MaterialLocalizations.of( + context, + ); + final String routeLabel = _getRouteLabel(localizations); + + return AnimatedBuilder( + animation: widget.route.animation!, + child: BottomSheet( + animationController: widget.route._animationController, + onClosing: () { + if (widget.route.isCurrent) { + Navigator.pop(context); + } + }, + builder: widget.route.builder, + backgroundColor: widget.backgroundColor, + elevation: widget.elevation, + shape: widget.shape, + clipBehavior: widget.clipBehavior, + constraints: widget.constraints, + enableDrag: widget.enableDrag, + showDragHandle: widget.showDragHandle, + onDragStart: handleDragStart, + onDragEnd: handleDragEnd, + ), + builder: (BuildContext context, Widget? child) { + final double animationValue = animationCurve.transform( + widget.route.animation!.value, + ); + return Semantics( + scopesRoute: true, + namesRoute: true, + label: routeLabel, + explicitChildNodes: true, + child: ClipRect( + child: _BottomSheetLayoutWithSizeListener( + onChildSizeChanged: (Size size) { + widget.route._didChangeBarrierSemanticsClip( + _getNewClipDetails(size), + ); + }, + animationValue: animationValue, + isScrollControlled: widget.isScrollControlled, + scrollControlDisabledMaxHeightRatio: + widget.scrollControlDisabledMaxHeightRatio, + child: child, + ), + ), + ); + }, + ); + } +} + +/// A route that represents a Material Design modal bottom sheet. +/// +/// {@template flutter.material.ModalBottomSheetRoute} +/// A modal bottom sheet is an alternative to a menu or a dialog and prevents +/// the user from interacting with the rest of the app. +/// +/// A closely related widget is a persistent bottom sheet, which shows +/// information that supplements the primary content of the app without +/// preventing the user from interacting with the app. Persistent bottom sheets +/// can be created and displayed with the [showBottomSheet] function or the +/// [ScaffoldState.showBottomSheet] method. +/// +/// The [isScrollControlled] parameter specifies whether this is a route for +/// a bottom sheet that will utilize [DraggableScrollableSheet]. Consider +/// setting this parameter to true if this bottom sheet has +/// a scrollable child, such as a [ListView] or a [GridView], +/// to have the bottom sheet be draggable. +/// +/// The [isDismissible] parameter specifies whether the bottom sheet will be +/// dismissed when user taps on the scrim. +/// +/// The [enableDrag] parameter specifies whether the bottom sheet can be +/// dragged up and down and dismissed by swiping downwards. +/// +/// The [useSafeArea] parameter specifies whether the sheet will avoid system +/// intrusions on the top, left, and right. If false, no [SafeArea] is added; +/// and [MediaQuery.removePadding] is applied to the top, +/// so that system intrusions at the top will not be avoided by a [SafeArea] +/// inside the bottom sheet either. +/// Defaults to false. +/// +/// The optional [backgroundColor], [elevation], [shape], [clipBehavior], +/// [constraints] and [transitionAnimationController] +/// parameters can be passed in to customize the appearance and behavior of +/// modal bottom sheets (see the documentation for these on [BottomSheet] +/// for more details). +/// +/// The [transitionAnimationController] controls the bottom sheet's entrance and +/// exit animations. It's up to the owner of the controller to call +/// [AnimationController.dispose] when the controller is no longer needed. +/// +/// The optional `settings` parameter sets the [RouteSettings] of the modal bottom sheet +/// sheet. This is particularly useful in the case that a user wants to observe +/// [PopupRoute]s within a [NavigatorObserver]. +/// {@endtemplate} +/// +/// {@macro flutter.widgets.RawDialogRoute} +/// +/// See also: +/// +/// * [showModalBottomSheet], which is a way to display a ModalBottomSheetRoute. +/// * [BottomSheet], which becomes the parent of the widget returned by the +/// function passed as the `builder` argument to [showModalBottomSheet]. +/// * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing +/// non-modal bottom sheets. +/// * [DraggableScrollableSheet], creates a bottom sheet that grows +/// and then becomes scrollable once it reaches its maximum size. +/// * [DisplayFeatureSubScreen], which documents the specifics of how +/// [DisplayFeature]s can split the screen into sub-screens. +/// * The Material 2 spec at . +/// * The Material 3 spec at . +class ModalBottomSheetRoute extends PopupRoute { + /// A modal bottom sheet route. + ModalBottomSheetRoute({ + required this.builder, + this.capturedThemes, + this.barrierLabel, + this.barrierOnTapHint, + this.backgroundColor, + this.elevation, + this.shape, + this.clipBehavior, + this.constraints, + this.modalBarrierColor, + this.isDismissible = true, + this.enableDrag = true, + this.showDragHandle, + required this.isScrollControlled, + this.scrollControlDisabledMaxHeightRatio = + _defaultScrollControlDisabledMaxHeightRatio, + super.settings, + super.requestFocus, + this.transitionAnimationController, + this.anchorPoint, + this.useSafeArea = false, + this.sheetAnimationStyle, + }); + + /// A builder for the contents of the sheet. + /// + /// The bottom sheet will wrap the widget produced by this builder in a + /// [Material] widget. + final WidgetBuilder builder; + + /// Stores a list of captured [InheritedTheme]s that are wrapped around the + /// bottom sheet. + /// + /// Consider setting this attribute when the [ModalBottomSheetRoute] + /// is created through [Navigator.push] and its friends. + final CapturedThemes? capturedThemes; + + /// Specifies whether this is a route for a bottom sheet that will utilize + /// [DraggableScrollableSheet]. + /// + /// Consider setting this parameter to true if this bottom sheet has + /// a scrollable child, such as a [ListView] or a [GridView], + /// to have the bottom sheet be draggable. + final bool isScrollControlled; + + /// The max height constraint ratio for the bottom sheet + /// when [isScrollControlled] is set to false, + /// no ratio will be applied when [isScrollControlled] is set to true. + /// + /// Defaults to 9 / 16. + final double scrollControlDisabledMaxHeightRatio; + + /// The bottom sheet's background color. + /// + /// Defines the bottom sheet's [Material.color]. + /// + /// If this property is not provided, it falls back to [Material]'s default. + final Color? backgroundColor; + + /// The z-coordinate at which to place this material relative to its parent. + /// + /// This controls the size of the shadow below the material. + /// + /// Defaults to 0, must not be negative. + final double? elevation; + + /// The shape of the bottom sheet. + /// + /// Defines the bottom sheet's [Material.shape]. + /// + /// If this property is not provided, it falls back to [Material]'s default. + final ShapeBorder? shape; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defines the bottom sheet's [Material.clipBehavior]. + /// + /// Use this property to enable clipping of content when the bottom sheet has + /// a custom [shape] and the content can extend past this shape. For example, + /// a bottom sheet with rounded corners and an edge-to-edge [Image] at the + /// top. + /// + /// If this property is null, the [BottomSheetThemeData.clipBehavior] of + /// [ThemeData.bottomSheetTheme] is used. If that's null, the behavior defaults to [Clip.none] + /// will be [Clip.none]. + final Clip? clipBehavior; + + /// Defines minimum and maximum sizes for a [BottomSheet]. + /// + /// If null, the ambient [ThemeData.bottomSheetTheme]'s + /// [BottomSheetThemeData.constraints] will be used. If that + /// is null and [ThemeData.useMaterial3] is true, then the bottom sheet + /// will have a max width of 640dp. If [ThemeData.useMaterial3] is false, then + /// the bottom sheet's size will be constrained by its parent + /// (usually a [Scaffold]). In this case, consider limiting the width by + /// setting smaller constraints for large screens. + /// + /// If constraints are specified (either in this property or in the + /// theme), the bottom sheet will be aligned to the bottom-center of + /// the available space. Otherwise, no alignment is applied. + final BoxConstraints? constraints; + + /// Specifies the color of the modal barrier that darkens everything below the + /// bottom sheet. + /// + /// Defaults to `Colors.black54` if not provided. + final Color? modalBarrierColor; + + /// Specifies whether the bottom sheet will be dismissed + /// when user taps on the scrim. + /// + /// If true, the bottom sheet will be dismissed when user taps on the scrim. + /// + /// Defaults to true. + final bool isDismissible; + + /// Specifies whether the bottom sheet can be dragged up and down + /// and dismissed by swiping downwards. + /// + /// If true, the bottom sheet can be dragged up and down and dismissed by + /// swiping downwards. + /// + /// This applies to the content below the drag handle, if showDragHandle is true. + /// + /// Defaults is true. + final bool enableDrag; + + /// Specifies whether a drag handle is shown. + /// + /// The drag handle appears at the top of the bottom sheet. The default color is + /// [ColorScheme.onSurfaceVariant] with an opacity of 0.4 and can be customized + /// using dragHandleColor. The default size is `Size(32,4)` and can be customized + /// with dragHandleSize. + /// + /// If null, then the value of [BottomSheetThemeData.showDragHandle] is used. If + /// that is also null, defaults to false. + final bool? showDragHandle; + + /// The animation controller that controls the bottom sheet's entrance and + /// exit animations. + /// + /// The BottomSheet widget will manipulate the position of this animation, it + /// is not just a passive observer. + final AnimationController? transitionAnimationController; + + /// {@macro flutter.widgets.DisplayFeatureSubScreen.anchorPoint} + final Offset? anchorPoint; + + /// Whether to avoid system intrusions on the top, left, and right. + /// + /// If true, a [SafeArea] is inserted to keep the bottom sheet away from + /// system intrusions at the top, left, and right sides of the screen. + /// + /// If false, the bottom sheet will extend through any system intrusions + /// at the top, left, and right. + /// + /// If false, then moreover [MediaQuery.removePadding] will be used + /// to remove top padding, so that a [SafeArea] widget inside the bottom + /// sheet will have no effect at the top edge. If this is undesired, consider + /// setting [useSafeArea] to true. Alternatively, wrap the [SafeArea] in a + /// [MediaQuery] that restates an ambient [MediaQueryData] from outside [builder]. + /// + /// In either case, the bottom sheet extends all the way to the bottom of + /// the screen, including any system intrusions. + /// + /// The default is false. + final bool useSafeArea; + + /// Used to override the modal bottom sheet animation duration and reverse + /// animation duration. + /// + /// If [AnimationStyle.duration] is provided, it will be used to override + /// the modal bottom sheet animation duration in the underlying + /// [BottomSheet.createAnimationController]. + /// + /// If [AnimationStyle.reverseDuration] is provided, it will be used to + /// override the modal bottom sheet reverse animation duration in the + /// underlying [BottomSheet.createAnimationController]. + /// + /// To disable the modal bottom sheet animation, use [AnimationStyle.noAnimation]. + final AnimationStyle? sheetAnimationStyle; + + /// {@template flutter.material.ModalBottomSheetRoute.barrierOnTapHint} + /// The semantic hint text that informs users what will happen if they + /// tap on the widget. Announced in the format of 'Double tap to ...'. + /// + /// If the field is null, the default hint will be used, which results in + /// announcement of 'Double tap to activate'. + /// {@endtemplate} + /// + /// See also: + /// + /// * [barrierDismissible], which controls the behavior of the barrier when + /// tapped. + /// * [ModalBarrier], which uses this field as onTapHint when it has an onTap action. + final String? barrierOnTapHint; + + final ValueNotifier _clipDetailsNotifier = + ValueNotifier(EdgeInsets.zero); + + @override + void dispose() { + _clipDetailsNotifier.dispose(); + super.dispose(); + } + + /// Updates the details regarding how the [SemanticsNode.rect] (focus) of + /// the barrier for this [ModalBottomSheetRoute] should be clipped. + /// + /// Returns true if the clipDetails did change and false otherwise. + bool _didChangeBarrierSemanticsClip(EdgeInsets newClipDetails) { + if (_clipDetailsNotifier.value == newClipDetails) { + return false; + } + _clipDetailsNotifier.value = newClipDetails; + return true; + } + + @override + Duration get transitionDuration => + transitionAnimationController?.duration ?? + sheetAnimationStyle?.duration ?? + _bottomSheetEnterDuration; + + @override + Duration get reverseTransitionDuration => + transitionAnimationController?.reverseDuration ?? + transitionAnimationController?.duration ?? + sheetAnimationStyle?.reverseDuration ?? + _bottomSheetExitDuration; + + @override + bool get barrierDismissible => isDismissible; + + @override + final String? barrierLabel; + + @override + Color get barrierColor => modalBarrierColor ?? Colors.black54; + + AnimationController? _animationController; + + @override + AnimationController createAnimationController() { + assert(_animationController == null); + if (transitionAnimationController != null) { + _animationController = transitionAnimationController; + willDisposeAnimationController = false; + } else { + _animationController = BottomSheet.createAnimationController( + navigator!, + sheetAnimationStyle: sheetAnimationStyle, + ); + } + return _animationController!; + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + final Widget content = DisplayFeatureSubScreen( + anchorPoint: anchorPoint, + child: Builder( + builder: (BuildContext context) { + final BottomSheetThemeData sheetTheme = Theme.of( + context, + ).bottomSheetTheme; + final BottomSheetThemeData defaults = Theme.of(context).useMaterial3 + ? _BottomSheetDefaultsM3(context) + : const BottomSheetThemeData(); + return _ModalBottomSheet( + route: this, + backgroundColor: + backgroundColor ?? + sheetTheme.modalBackgroundColor ?? + sheetTheme.backgroundColor ?? + defaults.backgroundColor, + elevation: + elevation ?? + sheetTheme.modalElevation ?? + sheetTheme.elevation ?? + defaults.modalElevation, + shape: shape, + clipBehavior: clipBehavior, + constraints: constraints, + isScrollControlled: isScrollControlled, + scrollControlDisabledMaxHeightRatio: + scrollControlDisabledMaxHeightRatio, + enableDrag: enableDrag, + showDragHandle: + showDragHandle ?? + (enableDrag && (sheetTheme.showDragHandle ?? false)), + ); + }, + ), + ); + + final Widget bottomSheet = useSafeArea + ? SafeArea(bottom: false, child: content) + : MediaQuery.removePadding( + context: context, + removeTop: true, + child: content, + ); + + return capturedThemes?.wrap(bottomSheet) ?? bottomSheet; + } + + @override + Widget buildModalBarrier() { + if (barrierColor.a != 0 && !offstage) { + // changedInternalState is called if barrierColor or offstage updates + assert(barrierColor != barrierColor.withValues(alpha: 0.0)); + final Animation color = animation!.drive( + ColorTween( + begin: barrierColor.withValues(alpha: 0.0), + end: + barrierColor, // changedInternalState is called if barrierColor updates + ).chain( + CurveTween(curve: barrierCurve), + ), // changedInternalState is called if barrierCurve updates + ); + return AnimatedModalBarrier( + color: color, + dismissible: + barrierDismissible, // changedInternalState is called if barrierDismissible updates + semanticsLabel: + barrierLabel, // changedInternalState is called if barrierLabel updates + barrierSemanticsDismissible: semanticsDismissible, + clipDetailsNotifier: _clipDetailsNotifier, + semanticsOnTapHint: barrierOnTapHint, + ); + } else { + return ModalBarrier( + dismissible: + barrierDismissible, // changedInternalState is called if barrierDismissible updates + semanticsLabel: + barrierLabel, // changedInternalState is called if barrierLabel updates + barrierSemanticsDismissible: semanticsDismissible, + clipDetailsNotifier: _clipDetailsNotifier, + semanticsOnTapHint: barrierOnTapHint, + ); + } + } +} + +/// Shows a modal Material Design bottom sheet. +/// +/// {@macro flutter.material.ModalBottomSheetRoute} +/// +/// {@macro flutter.widgets.RawDialogRoute} +/// +/// The `context` argument is used to look up the [Navigator] and [Theme] for +/// the bottom sheet. It is only used when the method is called. Its +/// corresponding widget can be safely removed from the tree before the bottom +/// sheet is closed. +/// +/// The `useRootNavigator` parameter ensures that the root navigator is used to +/// display the [BottomSheet] when set to `true`. This is useful in the case +/// that a modal [BottomSheet] needs to be displayed above all other content +/// but the caller is inside another [Navigator]. +/// +/// Returns a `Future` that resolves to the value (if any) that was passed to +/// [Navigator.pop] when the modal bottom sheet was closed. +/// +/// The 'barrierLabel' parameter can be used to set a custom barrier label. +/// Will default to [MaterialLocalizations.modalBarrierDismissLabel] of context +/// if not set. +/// +/// {@tool dartpad} +/// This example demonstrates how to use [showModalBottomSheet] to display a +/// bottom sheet that obscures the content behind it when a user taps a button. +/// It also demonstrates how to close the bottom sheet using the [Navigator] +/// when a user taps on a button inside the bottom sheet. +/// +/// ** See code in examples/api/lib/material/bottom_sheet/show_modal_bottom_sheet.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows the creation of [showModalBottomSheet], as described in: +/// https://m3.material.io/components/bottom-sheets/overview +/// +/// ** See code in examples/api/lib/material/bottom_sheet/show_modal_bottom_sheet.1.dart ** +/// {@end-tool} +/// +/// The [sheetAnimationStyle] parameter is used to override the modal bottom sheet +/// animation duration and reverse animation duration. +/// +/// The [requestFocus] parameter is used to specify whether the bottom sheet should +/// request focus when shown. +/// {@macro flutter.widgets.navigator.Route.requestFocus} +/// +/// If [AnimationStyle.duration] is provided, it will be used to override +/// the modal bottom sheet animation duration in the underlying +/// [BottomSheet.createAnimationController]. +/// +/// If [AnimationStyle.reverseDuration] is provided, it will be used to +/// override the modal bottom sheet reverse animation duration in the +/// underlying [BottomSheet.createAnimationController]. +/// +/// To disable the bottom sheet animation, use [AnimationStyle.noAnimation]. +/// +/// {@tool dartpad} +/// This sample showcases how to override the [showModalBottomSheet] animation +/// duration and reverse animation duration using [AnimationStyle]. +/// +/// ** See code in examples/api/lib/material/bottom_sheet/show_modal_bottom_sheet.2.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [BottomSheet], which becomes the parent of the widget returned by the +/// function passed as the `builder` argument to [showModalBottomSheet]. +/// * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing +/// non-modal bottom sheets. +/// * [DraggableScrollableSheet], creates a bottom sheet that grows +/// and then becomes scrollable once it reaches its maximum size. +/// * [DisplayFeatureSubScreen], which documents the specifics of how +/// [DisplayFeature]s can split the screen into sub-screens. +/// * The Material 2 spec at . +/// * The Material 3 spec at . +/// * [AnimationStyle], which is used to override the modal bottom sheet +/// animation duration and reverse animation duration. +Future showModalBottomSheet({ + required BuildContext context, + required WidgetBuilder builder, + Color? backgroundColor, + String? barrierLabel, + double? elevation, + ShapeBorder? shape, + Clip? clipBehavior, + BoxConstraints? constraints, + Color? barrierColor, + bool isScrollControlled = false, + double scrollControlDisabledMaxHeightRatio = + _defaultScrollControlDisabledMaxHeightRatio, + bool useRootNavigator = false, + bool isDismissible = true, + bool enableDrag = true, + bool? showDragHandle, + bool useSafeArea = false, + RouteSettings? routeSettings, + AnimationController? transitionAnimationController, + Offset? anchorPoint, + AnimationStyle? sheetAnimationStyle, + bool? requestFocus, +}) { + assert(debugCheckHasMediaQuery(context)); + assert(debugCheckHasMaterialLocalizations(context)); + + final NavigatorState navigator = Navigator.of( + context, + rootNavigator: useRootNavigator, + ); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + return navigator.push( + ModalBottomSheetRoute( + builder: builder, + capturedThemes: InheritedTheme.capture( + from: context, + to: navigator.context, + ), + isScrollControlled: isScrollControlled, + scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio, + barrierLabel: barrierLabel ?? localizations.scrimLabel, + barrierOnTapHint: localizations.scrimOnTapHint( + localizations.bottomSheetLabel, + ), + backgroundColor: backgroundColor, + elevation: elevation, + shape: shape, + clipBehavior: clipBehavior, + constraints: constraints, + isDismissible: isDismissible, + modalBarrierColor: + barrierColor ?? Theme.of(context).bottomSheetTheme.modalBarrierColor, + enableDrag: enableDrag, + showDragHandle: showDragHandle, + settings: routeSettings, + transitionAnimationController: transitionAnimationController, + anchorPoint: anchorPoint, + useSafeArea: useSafeArea, + sheetAnimationStyle: sheetAnimationStyle, + requestFocus: requestFocus, + ), + ); +} + +/// Shows a Material Design bottom sheet in the nearest [Scaffold] ancestor. To +/// show a persistent bottom sheet, use the [Scaffold.bottomSheet]. +/// +/// Returns a controller that can be used to close and otherwise manipulate the +/// bottom sheet. +/// +/// The optional [backgroundColor], [elevation], [shape], [clipBehavior], +/// [constraints] and [transitionAnimationController] +/// parameters can be passed in to customize the appearance and behavior of +/// persistent bottom sheets (see the documentation for these on [BottomSheet] +/// for more details). +/// +/// The [enableDrag] parameter specifies whether the bottom sheet can be +/// dragged up and down and dismissed by swiping downwards. +/// +/// The [sheetAnimationStyle] parameter is used to override the bottom sheet +/// animation duration and reverse animation duration. +/// +/// If [AnimationStyle.duration] is provided, it will be used to override +/// the bottom sheet animation duration in the underlying +/// [BottomSheet.createAnimationController]. +/// +/// If [AnimationStyle.reverseDuration] is provided, it will be used to +/// override the bottom sheet reverse animation duration in the underlying +/// [BottomSheet.createAnimationController]. +/// +/// To disable the bottom sheet animation, use [AnimationStyle.noAnimation]. +/// +/// {@tool dartpad} +/// This sample showcases how to override the [showBottomSheet] animation +/// duration and reverse animation duration using [AnimationStyle]. +/// +/// ** See code in examples/api/lib/material/bottom_sheet/show_bottom_sheet.0.dart ** +/// {@end-tool} +/// +/// To rebuild the bottom sheet (e.g. if it is stateful), call +/// [PersistentBottomSheetController.setState] on the controller returned by +/// this method. +/// +/// The new bottom sheet becomes a [LocalHistoryEntry] for the enclosing +/// [ModalRoute] and a back button is added to the app bar of the [Scaffold] +/// that closes the bottom sheet. +/// +/// To create a persistent bottom sheet that is not a [LocalHistoryEntry] and +/// does not add a back button to the enclosing Scaffold's app bar, use the +/// [Scaffold.bottomSheet] constructor parameter. +/// +/// A closely related widget is a modal bottom sheet, which is an alternative +/// to a menu or a dialog and prevents the user from interacting with the rest +/// of the app. Modal bottom sheets can be created and displayed with the +/// [showModalBottomSheet] function. +/// +/// The `context` argument is used to look up the [Scaffold] for the bottom +/// sheet. It is only used when the method is called. Its corresponding widget +/// can be safely removed from the tree before the bottom sheet is closed. +/// +/// See also: +/// +/// * [BottomSheet], which becomes the parent of the widget returned by the +/// `builder`. +/// * [showModalBottomSheet], which can be used to display a modal bottom +/// sheet. +/// * [Scaffold.of], for information about how to obtain the [BuildContext]. +/// * The Material 2 spec at . +/// * The Material 3 spec at . +/// * [AnimationStyle], which is used to override the bottom sheet animation +/// duration and reverse animation duration. +bool debugCheckHasScaffold(BuildContext context) { + assert(() { + if (context.widget is! Scaffold && + context.findAncestorWidgetOfExactType() == null) { + throw FlutterError.fromParts([ + ErrorSummary('No Scaffold widget found.'), + ErrorDescription( + '${context.widget.runtimeType} widgets require a Scaffold widget ancestor.', + ), + ...context.describeMissingAncestor(expectedAncestorType: Scaffold), + ErrorHint( + 'Typically, the Scaffold widget is introduced by the MaterialApp or ' + 'WidgetsApp widget at the top of your application widget tree.', + ), + ]); + } + return true; + }()); + return true; +} + +PersistentBottomSheetController showBottomSheet({ + required BuildContext context, + required WidgetBuilder builder, + Color? backgroundColor, + double? elevation, + ShapeBorder? shape, + Clip? clipBehavior, + BoxConstraints? constraints, + bool? enableDrag, + bool? showDragHandle, + AnimationController? transitionAnimationController, + AnimationStyle? sheetAnimationStyle, +}) { + assert(debugCheckHasScaffold(context)); + + return Scaffold.of(context).showBottomSheet( + builder, + backgroundColor: backgroundColor, + elevation: elevation, + shape: shape, + clipBehavior: clipBehavior, + constraints: constraints, + enableDrag: enableDrag, + showDragHandle: showDragHandle, + transitionAnimationController: transitionAnimationController, + sheetAnimationStyle: sheetAnimationStyle, + ); +} + +class _BottomSheetGestureDetector extends StatelessWidget { + const _BottomSheetGestureDetector({ + required this.child, + required this.onVerticalDragStart, + required this.onVerticalDragUpdate, + required this.onVerticalDragEnd, + }); + + final Widget child; + final GestureDragStartCallback onVerticalDragStart; + final GestureDragUpdateCallback onVerticalDragUpdate; + final GestureDragEndCallback onVerticalDragEnd; + + @override + Widget build(BuildContext context) { + return RawGestureDetector( + excludeFromSemantics: true, + gestures: >{ + VerticalDragGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => VerticalDragGestureRecognizer(debugOwner: this), + (VerticalDragGestureRecognizer instance) { + instance + ..onStart = onVerticalDragStart + ..onUpdate = onVerticalDragUpdate + ..onEnd = onVerticalDragEnd + ..gestureSettings = MediaQuery.maybeGestureSettingsOf(context) + ..onlyAcceptDragOnThreshold = true; + }, + ), + }, + child: child, + ); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - BottomSheet + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +// dart format off +class _BottomSheetDefaultsM3 extends BottomSheetThemeData { + _BottomSheetDefaultsM3(this.context) + : super( + elevation: 1.0, + modalElevation: 1.0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28.0))), + constraints: const BoxConstraints(maxWidth: 640), + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + Color? get backgroundColor => _colors.surfaceContainerLow; + + @override + Color? get surfaceTintColor => Colors.transparent; + + @override + Color? get shadowColor => Colors.transparent; + + @override + Color? get dragHandleColor => _colors.onSurfaceVariant; + + @override + Size? get dragHandleSize => const Size(32, 4); + + @override + BoxConstraints? get constraints => const BoxConstraints(maxWidth: 640.0); +} +// dart format on + +// END GENERATED TOKEN PROPERTIES - BottomSheet diff --git a/lib/common/widgets/scaffold/scaffold.dart b/lib/common/widgets/scaffold/scaffold.dart new file mode 100644 index 000000000..95157bfdf --- /dev/null +++ b/lib/common/widgets/scaffold/scaffold.dart @@ -0,0 +1,3556 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: uri_does_not_exist_in_doc_import + +/// @docImport 'package:flutter/services.dart'; +/// +/// @docImport 'app.dart'; +/// @docImport 'bottom_app_bar.dart'; +/// @docImport 'bottom_navigation_bar.dart'; +/// @docImport 'bottom_sheet_theme.dart'; +/// @docImport 'drawer_theme.dart'; +/// @docImport 'icon_button.dart'; +/// @docImport 'tab_controller.dart'; +/// @docImport 'tabs.dart'; +/// @docImport 'text_button.dart'; +library; + +import 'dart:async'; +import 'dart:collection'; +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:PiliPlus/common/widgets/scaffold/bottom_sheet.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/material.dart' + hide showBottomSheet, showModalBottomSheet, BottomSheet; +import 'package:flutter/material.dart' as material; + +// Examples can assume: +// late TabController tabController; +// void setState(VoidCallback fn) { } +// late String appBarTitle; +// late int tabCount; +// late TickerProvider tickerProvider; + +const FloatingActionButtonLocation _kDefaultFloatingActionButtonLocation = + FloatingActionButtonLocation.endFloat; +const FloatingActionButtonAnimator _kDefaultFloatingActionButtonAnimator = + FloatingActionButtonAnimator.scaling; + +const Curve _standardBottomSheetCurve = standardEasing; +// When the top of the BottomSheet crosses this threshold, it will start to +// shrink the FAB and show a scrim. +const double _kBottomSheetDominatesPercentage = 0.3; +const double _kMinBottomSheetScrimOpacity = 0.1; +const double _kMaxBottomSheetScrimOpacity = 0.6; + +enum _ScaffoldSlot { + body, + appBar, + bodyScrim, + bottomSheet, + snackBar, + materialBanner, + persistentFooter, + bottomNavigationBar, + floatingActionButton, + drawer, + endDrawer, + statusBar, +} + +/// Manages [SnackBar]s and [MaterialBanner]s for descendant [Scaffold]s. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=lytQi-slT5Y} +/// +/// This class provides APIs for showing snack bars and material banners at the +/// bottom and top of the screen, respectively. +/// +/// To display one of these notifications, obtain the [ScaffoldMessengerState] +/// for the current [BuildContext] via [ScaffoldMessenger.of] and use the +/// [ScaffoldMessengerState.showSnackBar] or the +/// [ScaffoldMessengerState.showMaterialBanner] functions. +/// +/// When the [ScaffoldMessenger] has nested [Scaffold] descendants, the +/// ScaffoldMessenger will only present the notification to the root Scaffold of +/// the subtree of Scaffolds. In order to show notifications for the inner, nested +/// Scaffolds, set a new scope by instantiating a new ScaffoldMessenger in +/// between the levels of nesting. +/// +/// {@tool dartpad} +/// Here is an example of showing a [SnackBar] when the user presses a button. +/// +/// ** See code in examples/api/lib/material/scaffold/scaffold_messenger.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SnackBar], which is a temporary notification typically shown near the +/// bottom of the app using the [ScaffoldMessengerState.showSnackBar] method. +/// * [MaterialBanner], which is a temporary notification typically shown at the +/// top of the app using the [ScaffoldMessengerState.showMaterialBanner] method. +/// * [debugCheckHasScaffoldMessenger], which asserts that the given context +/// has a [ScaffoldMessenger] ancestor. +/// * Cookbook: [Display a SnackBar](https://docs.flutter.dev/cookbook/design/snackbars) +class ScaffoldMessenger extends StatefulWidget { + /// Creates a widget that manages [SnackBar]s for [Scaffold] descendants. + const ScaffoldMessenger({super.key, required this.child}); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + /// The state from the closest instance of this class that encloses the given + /// context. + /// + /// {@tool dartpad} + /// Typical usage of the [ScaffoldMessenger.of] function is to call it in + /// response to a user gesture or an application state change. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger.of.0.dart ** + /// {@end-tool} + /// + /// A less elegant but more expedient solution is to assign a [GlobalKey] to the + /// [ScaffoldMessenger], then use the `key.currentState` property to obtain the + /// [ScaffoldMessengerState] rather than using the [ScaffoldMessenger.of] + /// function. The [MaterialApp.scaffoldMessengerKey] refers to the root + /// ScaffoldMessenger that is provided by default. + /// + /// {@tool dartpad} + /// Sometimes [SnackBar]s are produced by code that doesn't have ready access + /// to a valid [BuildContext]. One such example of this is when you show a + /// SnackBar from a method outside of the `build` function. In these + /// cases, you can assign a [GlobalKey] to the [ScaffoldMessenger]. This + /// example shows a key being used to obtain the [ScaffoldMessengerState] + /// provided by the [MaterialApp]. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger.of.1.dart ** + /// {@end-tool} + /// + /// If there is no [ScaffoldMessenger] in scope, then this will assert in + /// debug mode, and throw an exception in release mode. + /// + /// See also: + /// + /// * [maybeOf], which is a similar function but will return null instead of + /// throwing if there is no [ScaffoldMessenger] ancestor. + /// * [debugCheckHasScaffoldMessenger], which asserts that the given context + /// has a [ScaffoldMessenger] ancestor. + static ScaffoldMessengerState of(BuildContext context) { + assert(debugCheckHasScaffoldMessenger(context)); + + final _ScaffoldMessengerScope scope = context + .dependOnInheritedWidgetOfExactType<_ScaffoldMessengerScope>()!; + return scope._scaffoldMessengerState; + } + + /// The state from the closest instance of this class that encloses the given + /// context, if any. + /// + /// Will return null if a [ScaffoldMessenger] is not found in the given context. + /// + /// See also: + /// + /// * [of], which is a similar function, except that it will throw an + /// exception if a [ScaffoldMessenger] is not found in the given context. + static ScaffoldMessengerState? maybeOf(BuildContext context) { + final _ScaffoldMessengerScope? scope = context + .dependOnInheritedWidgetOfExactType<_ScaffoldMessengerScope>(); + return scope?._scaffoldMessengerState; + } + + @override + ScaffoldMessengerState createState() => ScaffoldMessengerState(); +} + +/// State for a [ScaffoldMessenger]. +/// +/// A [ScaffoldMessengerState] object can be used to [showSnackBar] or +/// [showMaterialBanner] for every registered [Scaffold] that is a descendant of +/// the associated [ScaffoldMessenger]. Scaffolds will register to receive +/// [SnackBar]s and [MaterialBanner]s from their closest ScaffoldMessenger +/// ancestor. +/// +/// Typically obtained via [ScaffoldMessenger.of]. +class ScaffoldMessengerState extends State + with TickerProviderStateMixin { + final LinkedHashSet _scaffolds = + LinkedHashSet(); + final Queue< + ScaffoldFeatureController + > + _materialBanners = + Queue< + ScaffoldFeatureController + >(); + AnimationController? _materialBannerController; + final Queue> + _snackBars = + Queue>(); + AnimationController? _snackBarController; + Timer? _snackBarTimer; + bool? _accessibleNavigation; + + @protected + @override + void didChangeDependencies() { + final bool accessibleNavigation = MediaQuery.accessibleNavigationOf( + context, + ); + // If we transition from accessible navigation to non-accessible navigation + // and there is a SnackBar that would have timed out that has already + // completed its timer, dismiss that SnackBar. If the timer hasn't finished + // yet, let it timeout as normal. + if ((_accessibleNavigation ?? false) && + !accessibleNavigation && + _snackBarTimer != null && + !_snackBarTimer!.isActive) { + hideCurrentSnackBar(reason: SnackBarClosedReason.timeout); + } + _accessibleNavigation = accessibleNavigation; + super.didChangeDependencies(); + } + + void _register(ScaffoldState scaffold) { + _scaffolds.add(scaffold); + + if (_isRoot(scaffold)) { + if (_snackBars.isNotEmpty) { + scaffold._updateSnackBar(); + } + + if (_materialBanners.isNotEmpty) { + scaffold._updateMaterialBanner(); + } + } + } + + void _unregister(ScaffoldState scaffold) { + final bool removed = _scaffolds.remove(scaffold); + // ScaffoldStates should only be removed once. + assert(removed); + } + + void _updateScaffolds() { + for (final ScaffoldState scaffold in _scaffolds) { + if (_isRoot(scaffold)) { + scaffold._updateSnackBar(); + scaffold._updateMaterialBanner(); + } + } + } + + // Nested Scaffolds are handled by the ScaffoldMessenger by only presenting a + // MaterialBanner or SnackBar in the root Scaffold of the nested set. + bool _isRoot(ScaffoldState scaffold) { + final ScaffoldState? parent = scaffold.context + .findAncestorStateOfType(); + return parent == null || !_scaffolds.contains(parent); + } + + // SNACKBAR API + + /// Shows a [SnackBar] across all registered [Scaffold]s. Scaffolds register + /// to receive snack bars from their closest [ScaffoldMessenger] ancestor. + /// If there are several registered scaffolds the snack bar is shown + /// simultaneously on all of them. + /// + /// A scaffold can show at most one snack bar at a time. If this function is + /// called while another snack bar is already visible, the given snack bar + /// will be added to a queue and displayed after the earlier snack bars have + /// closed. + /// + /// To control how long a [SnackBar] remains visible, use [SnackBar.duration]. + /// + /// To remove the [SnackBar] with an exit animation, use [hideCurrentSnackBar] + /// or call [ScaffoldFeatureController.close] on the returned + /// [ScaffoldFeatureController]. To remove a [SnackBar] suddenly (without an + /// animation), use [removeCurrentSnackBar]. + /// + /// See [ScaffoldMessenger.of] for information about how to obtain the + /// [ScaffoldMessengerState]. + /// + /// {@tool dartpad} + /// Here is an example of showing a [SnackBar] when the user presses a button. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.0.dart ** + /// {@end-tool} + /// + /// ## Relative positioning of floating SnackBars + /// + /// A [SnackBar] with [SnackBar.behavior] set to [SnackBarBehavior.floating] is + /// positioned above the widgets provided to [Scaffold.floatingActionButton], + /// [Scaffold.persistentFooterButtons], and [Scaffold.bottomNavigationBar]. + /// If some or all of these widgets take up enough space such that the SnackBar + /// would not be visible when positioned above them, an error will be thrown. + /// In this case, consider constraining the size of these widgets to allow room for + /// the SnackBar to be visible. + /// + /// {@tool dartpad} + /// Here is an example showing how to display a [SnackBar] with [showSnackBar] + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.0.dart ** + /// {@end-tool} + /// + /// {@tool dartpad} + /// Here is an example showing that a floating [SnackBar] appears above [Scaffold.floatingActionButton]. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.1.dart ** + /// {@end-tool} + /// + /// If [AnimationStyle.duration] is provided in the [snackBarAnimationStyle] + /// parameter, it will be used to override the snackbar show animation duration. + /// Otherwise, defaults to 250ms. + /// + /// If [AnimationStyle.reverseDuration] is provided in the [snackBarAnimationStyle] + /// parameter, it will be used to override the snackbar hide animation duration. + /// Otherwise, defaults to 250ms. + /// + /// To disable the snackbar animation, use [AnimationStyle.noAnimation]. + /// + /// {@tool dartpad} + /// This sample showcases how to override [SnackBar] show and hide animation + /// duration using [AnimationStyle] in [ScaffoldMessengerState.showSnackBar]. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.2.dart ** + /// {@end-tool} + /// + ScaffoldFeatureController showSnackBar( + SnackBar snackBar, { + AnimationStyle? snackBarAnimationStyle, + }) { + assert( + _scaffolds.isNotEmpty, + 'ScaffoldMessenger.showSnackBar was called, but there are currently no ' + 'descendant Scaffolds to present to.', + ); + _didUpdateAnimationStyle(snackBarAnimationStyle); + _snackBarController ??= SnackBar.createAnimationController( + duration: snackBarAnimationStyle?.duration, + reverseDuration: snackBarAnimationStyle?.reverseDuration, + vsync: this, + )..addStatusListener(_handleSnackBarStatusChanged); + if (_snackBars.isEmpty) { + assert(_snackBarController!.isDismissed); + _snackBarController!.forward(); + } + late ScaffoldFeatureController controller; + controller = ScaffoldFeatureController._( + // We provide a fallback key so that if back-to-back snackbars happen to + // match in structure, material ink splashes and highlights don't survive + // from one to the next. + snackBar.withAnimation(_snackBarController!, fallbackKey: UniqueKey()), + Completer(), + () { + assert(_snackBars.first == controller); + hideCurrentSnackBar(); + }, + null, // SnackBar doesn't use a builder function so setState() wouldn't rebuild it + ); + try { + setState(() { + _snackBars.addLast(controller); + }); + _updateScaffolds(); + } catch (exception) { + assert(() { + if (exception is FlutterError) { + final String summary = exception.diagnostics.first.toDescription(); + if (summary == + 'setState() or markNeedsBuild() called during build.') { + final List information = [ + ErrorSummary( + 'The showSnackBar() method cannot be called during build.', + ), + ErrorDescription( + 'The showSnackBar() method was called during build, which is ' + 'prohibited as showing snack bars requires updating state. Updating ' + 'state is not possible during build.', + ), + ErrorHint( + 'Instead of calling showSnackBar() during build, call it directly ' + 'in your on tap (and related) callbacks. If you need to immediately ' + 'show a snack bar, make the call in initState() or ' + 'didChangeDependencies() instead. Otherwise, you can also schedule a ' + 'post-frame callback using SchedulerBinding.addPostFrameCallback to ' + 'show the snack bar after the current frame.', + ), + context.describeOwnershipChain( + 'The ownership chain for the particular ScaffoldMessenger is', + ), + ]; + throw FlutterError.fromParts(information); + } + } + return true; + }()); + rethrow; + } + + return controller; + } + + void _didUpdateAnimationStyle(AnimationStyle? snackBarAnimationStyle) { + if (snackBarAnimationStyle != null) { + if (_snackBarController?.duration != snackBarAnimationStyle.duration || + _snackBarController?.reverseDuration != + snackBarAnimationStyle.reverseDuration) { + _snackBarController?.dispose(); + _snackBarController = null; + } + } + } + + void _handleSnackBarStatusChanged(AnimationStatus status) { + switch (status) { + case AnimationStatus.dismissed: + assert(_snackBars.isNotEmpty); + setState(() { + _snackBars.removeFirst(); + }); + _updateScaffolds(); + if (_snackBars.isNotEmpty) { + _snackBarController!.forward(); + } + case AnimationStatus.completed: + setState(() { + assert(_snackBarTimer == null); + // build will create a new timer if necessary to dismiss the snackBar. + }); + _updateScaffolds(); + case AnimationStatus.forward: + case AnimationStatus.reverse: + break; + } + } + + /// Removes the current [SnackBar] (if any) immediately from registered + /// [Scaffold]s. + /// + /// The removed snack bar does not run its normal exit animation. If there are + /// any queued snack bars, they begin their entrance animation immediately. + void removeCurrentSnackBar({ + SnackBarClosedReason reason = SnackBarClosedReason.remove, + }) { + if (_snackBars.isEmpty) { + return; + } + final Completer completer = + _snackBars.first._completer; + if (!completer.isCompleted) { + completer.complete(reason); + } + _snackBarTimer?.cancel(); + _snackBarTimer = null; + // This will trigger the animation's status callback. + _snackBarController!.value = 0.0; + } + + /// Removes the current [SnackBar] by running its normal exit animation. + /// + /// The closed completer is called after the animation is complete. + void hideCurrentSnackBar({ + SnackBarClosedReason reason = SnackBarClosedReason.hide, + }) { + if (_snackBars.isEmpty || _snackBarController!.isDismissed) { + return; + } + final Completer completer = + _snackBars.first._completer; + if (_accessibleNavigation!) { + _snackBarController!.value = 0.0; + completer.complete(reason); + } else { + _snackBarController!.reverse().then((void value) { + assert(mounted); + if (!completer.isCompleted) { + completer.complete(reason); + } + }); + } + _snackBarTimer?.cancel(); + _snackBarTimer = null; + } + + /// Removes all the snackBars currently in queue by clearing the queue + /// and running normal exit animation on the current snackBar. + void clearSnackBars() { + if (_snackBars.isEmpty || _snackBarController!.isDismissed) { + return; + } + final ScaffoldFeatureController + currentSnackbar = _snackBars.first; + _snackBars.clear(); + _snackBars.add(currentSnackbar); + hideCurrentSnackBar(); + } + + // MATERIAL BANNER API + + /// Shows a [MaterialBanner] across all registered [Scaffold]s. Scaffolds register + /// to receive material banners from their closest [ScaffoldMessenger] ancestor. + /// If there are several registered scaffolds the material banner is shown + /// simultaneously on all of them. + /// + /// A scaffold can show at most one material banner at a time. If this function is + /// called while another material banner is already visible, the given material banner + /// will be added to a queue and displayed after the earlier material banners have + /// closed. + /// + /// To remove the [MaterialBanner] with an exit animation, use [hideCurrentMaterialBanner] + /// or call [ScaffoldFeatureController.close] on the returned + /// [ScaffoldFeatureController]. To remove a [MaterialBanner] suddenly (without an + /// animation), use [removeCurrentMaterialBanner]. + /// + /// See [ScaffoldMessenger.of] for information about how to obtain the + /// [ScaffoldMessengerState]. + /// + /// {@tool dartpad} + /// Here is an example of showing a [MaterialBanner] when the user presses a button. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger_state.show_material_banner.0.dart ** + /// {@end-tool} + ScaffoldFeatureController + showMaterialBanner( + MaterialBanner materialBanner, + ) { + assert( + _scaffolds.isNotEmpty, + 'ScaffoldMessenger.showMaterialBanner was called, but there are currently no ' + 'descendant Scaffolds to present to.', + ); + _materialBannerController ??= MaterialBanner.createAnimationController( + vsync: this, + )..addStatusListener(_handleMaterialBannerStatusChanged); + if (_materialBanners.isEmpty) { + assert(_materialBannerController!.isDismissed); + _materialBannerController!.forward(); + } + late ScaffoldFeatureController + controller; + controller = ScaffoldFeatureController._( + // We provide a fallback key so that if back-to-back material banners happen to + // match in structure, material ink splashes and highlights don't survive + // from one to the next. + materialBanner.withAnimation( + _materialBannerController!, + fallbackKey: UniqueKey(), + ), + Completer(), + () { + assert(_materialBanners.first == controller); + hideCurrentMaterialBanner(); + }, + null, // MaterialBanner doesn't use a builder function so setState() wouldn't rebuild it + ); + setState(() { + _materialBanners.addLast(controller); + }); + _updateScaffolds(); + return controller; + } + + void _handleMaterialBannerStatusChanged(AnimationStatus status) { + switch (status) { + case AnimationStatus.dismissed: + assert(_materialBanners.isNotEmpty); + setState(() { + _materialBanners.removeFirst(); + }); + _updateScaffolds(); + if (_materialBanners.isNotEmpty) { + _materialBannerController!.forward(); + } + case AnimationStatus.completed: + _updateScaffolds(); + case AnimationStatus.forward: + case AnimationStatus.reverse: + break; + } + } + + /// Removes the current [MaterialBanner] (if any) immediately from registered + /// [Scaffold]s. + /// + /// The removed material banner does not run its normal exit animation. If there are + /// any queued material banners, they begin their entrance animation immediately. + void removeCurrentMaterialBanner({ + MaterialBannerClosedReason reason = MaterialBannerClosedReason.remove, + }) { + if (_materialBanners.isEmpty) { + return; + } + final Completer completer = + _materialBanners.first._completer; + if (!completer.isCompleted) { + completer.complete(reason); + } + + // This will trigger the animation's status callback. + _materialBannerController!.value = 0.0; + } + + /// Removes the current [MaterialBanner] by running its normal exit animation. + /// + /// The closed completer is called after the animation is complete. + void hideCurrentMaterialBanner({ + MaterialBannerClosedReason reason = MaterialBannerClosedReason.hide, + }) { + if (_materialBanners.isEmpty || _materialBannerController!.isDismissed) { + return; + } + final Completer completer = + _materialBanners.first._completer; + if (_accessibleNavigation!) { + _materialBannerController!.value = 0.0; + completer.complete(reason); + } else { + _materialBannerController!.reverse().then((void value) { + assert(mounted); + if (!completer.isCompleted) { + completer.complete(reason); + } + }); + } + } + + /// Removes all the [MaterialBanner]s currently in queue by clearing the queue + /// and running normal exit animation on the current [MaterialBanner]. + void clearMaterialBanners() { + if (_materialBanners.isEmpty || _materialBannerController!.isDismissed) { + return; + } + final ScaffoldFeatureController + currentMaterialBanner = _materialBanners.first; + _materialBanners.clear(); + _materialBanners.add(currentMaterialBanner); + hideCurrentMaterialBanner(); + } + + @protected + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + _accessibleNavigation = MediaQuery.accessibleNavigationOf(context); + + if (_snackBars.isNotEmpty) { + final ModalRoute? route = ModalRoute.of(context); + if (route == null || route.isCurrent) { + if (_snackBarController!.isCompleted && _snackBarTimer == null) { + final SnackBar snackBar = _snackBars.first._widget; + _snackBarTimer = Timer(snackBar.duration, () { + assert(_snackBarController!.isForwardOrCompleted); + // Look up MediaQuery again in case the setting changed. + if (snackBar.action != null && + MediaQuery.accessibleNavigationOf(context)) { + return; + } + hideCurrentSnackBar(reason: SnackBarClosedReason.timeout); + }); + } + } + } + + return _ScaffoldMessengerScope( + scaffoldMessengerState: this, + child: widget.child, + ); + } + + @protected + @override + void dispose() { + _materialBannerController?.dispose(); + _snackBarController?.dispose(); + _snackBarTimer?.cancel(); + _snackBarTimer = null; + super.dispose(); + } +} + +class _ScaffoldMessengerScope extends InheritedWidget { + const _ScaffoldMessengerScope({ + required super.child, + required ScaffoldMessengerState scaffoldMessengerState, + }) : _scaffoldMessengerState = scaffoldMessengerState; + + final ScaffoldMessengerState _scaffoldMessengerState; + + @override + bool updateShouldNotify(_ScaffoldMessengerScope old) => + _scaffoldMessengerState != old._scaffoldMessengerState; +} + +/// A snapshot of a transition between two [FloatingActionButtonLocation]s. +/// +/// [ScaffoldState] uses this to seamlessly change transition animations +/// when a running [FloatingActionButtonLocation] transition is interrupted by a new transition. +@immutable +class _TransitionSnapshotFabLocation extends FloatingActionButtonLocation { + const _TransitionSnapshotFabLocation( + this.begin, + this.end, + this.animator, + this.progress, + ); + + final FloatingActionButtonLocation begin; + final FloatingActionButtonLocation end; + final FloatingActionButtonAnimator animator; + final double progress; + + @override + Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) { + return animator.getOffset( + begin: begin.getOffset(scaffoldGeometry), + end: end.getOffset(scaffoldGeometry), + progress: progress, + ); + } + + @override + String toString() { + return '${objectRuntimeType(this, '_TransitionSnapshotFabLocation')}(begin: $begin, end: $end, progress: $progress)'; + } +} + +/// Geometry information for [Scaffold] components after layout is finished. +/// +/// To get a [ValueNotifier] for the scaffold geometry of a given +/// [BuildContext], use [Scaffold.geometryOf]. +/// +/// The ScaffoldGeometry is only available during the paint phase, because +/// its value is computed during the animation and layout phases prior to painting. +/// +/// For an example of using the [ScaffoldGeometry], see the [BottomAppBar], +/// which uses the [ScaffoldGeometry] to paint a notch around the +/// [FloatingActionButton]. +/// +/// For information about the [Scaffold]'s geometry that is used while laying +/// out the [FloatingActionButton], see [ScaffoldPrelayoutGeometry]. +@immutable +class ScaffoldGeometry { + /// Create an object that describes the geometry of a [Scaffold]. + const ScaffoldGeometry({ + this.bottomNavigationBarTop, + this.floatingActionButtonArea, + }); + + /// The distance from the [Scaffold]'s top edge to the top edge of the + /// rectangle in which the [Scaffold.bottomNavigationBar] bar is laid out. + /// + /// Null if [Scaffold.bottomNavigationBar] is null. + final double? bottomNavigationBarTop; + + /// The [Scaffold.floatingActionButton]'s bounding rectangle. + /// + /// This is null when there is no floating action button showing. + final Rect? floatingActionButtonArea; + + ScaffoldGeometry _scaleFloatingActionButton(double scaleFactor) { + if (scaleFactor == 1.0) { + return this; + } + + if (scaleFactor == 0.0) { + return ScaffoldGeometry(bottomNavigationBarTop: bottomNavigationBarTop); + } + + final Rect scaledButton = Rect.lerp( + floatingActionButtonArea!.center & Size.zero, + floatingActionButtonArea, + scaleFactor, + )!; + return copyWith(floatingActionButtonArea: scaledButton); + } + + /// Creates a copy of this [ScaffoldGeometry] but with the given fields replaced with + /// the new values. + ScaffoldGeometry copyWith({ + double? bottomNavigationBarTop, + Rect? floatingActionButtonArea, + }) { + return ScaffoldGeometry( + bottomNavigationBarTop: + bottomNavigationBarTop ?? this.bottomNavigationBarTop, + floatingActionButtonArea: + floatingActionButtonArea ?? this.floatingActionButtonArea, + ); + } +} + +class _ScaffoldGeometryNotifier extends ChangeNotifier + implements ValueListenable { + _ScaffoldGeometryNotifier(this.geometry, this.context); + + final BuildContext context; + double? floatingActionButtonScale; + ScaffoldGeometry geometry; + + @override + ScaffoldGeometry get value { + assert(() { + final RenderObject? renderObject = context.findRenderObject(); + if (renderObject == null || !renderObject.owner!.debugDoingPaint) { + throw FlutterError( + 'Scaffold.geometryOf() must only be accessed during the paint phase.\n' + 'The ScaffoldGeometry is only available during the paint phase, because ' + 'its value is computed during the animation and layout phases prior to painting.', + ); + } + return true; + }()); + return geometry._scaleFloatingActionButton(floatingActionButtonScale!); + } + + void _updateWith({ + double? bottomNavigationBarTop, + Rect? floatingActionButtonArea, + double? floatingActionButtonScale, + }) { + this.floatingActionButtonScale = + floatingActionButtonScale ?? this.floatingActionButtonScale; + geometry = geometry.copyWith( + bottomNavigationBarTop: bottomNavigationBarTop, + floatingActionButtonArea: floatingActionButtonArea, + ); + notifyListeners(); + } +} + +// Used to communicate the height of the Scaffold's bottomNavigationBar and +// persistentFooterButtons to the LayoutBuilder which builds the Scaffold's body. +// +// Scaffold expects a _BodyBoxConstraints to be passed to the _BodyBuilder +// widget's LayoutBuilder, see _ScaffoldLayout.performLayout(). The BoxConstraints +// methods that construct new BoxConstraints objects, like copyWith() have not +// been overridden here because we expect the _BodyBoxConstraintsObject to be +// passed along unmodified to the LayoutBuilder. If that changes in the future +// then _BodyBuilder will assert. +class _BodyBoxConstraints extends BoxConstraints { + const _BodyBoxConstraints({ + super.maxWidth, + super.maxHeight, + required this.bottomWidgetsHeight, + required this.appBarHeight, + required this.materialBannerHeight, + }) : assert(bottomWidgetsHeight >= 0), + assert(appBarHeight >= 0), + assert(materialBannerHeight >= 0); + + final double bottomWidgetsHeight; + final double appBarHeight; + final double materialBannerHeight; + + // RenderObject.layout() will only short-circuit its call to its performLayout + // method if the new layout constraints are not == to the current constraints. + // If the height of the bottom widgets has changed, even though the constraints' + // min and max values have not, we still want performLayout to happen. + @override + bool operator ==(Object other) { + if (super != other) { + return false; + } + return other is _BodyBoxConstraints && + other.materialBannerHeight == materialBannerHeight && + other.bottomWidgetsHeight == bottomWidgetsHeight && + other.appBarHeight == appBarHeight; + } + + @override + int get hashCode => Object.hash( + super.hashCode, + materialBannerHeight, + bottomWidgetsHeight, + appBarHeight, + ); +} + +// Used when Scaffold.extendBody is true to wrap the scaffold's body in a MediaQuery +// whose padding accounts for the height of the bottomNavigationBar and/or the +// persistentFooterButtons. +// +// The bottom widgets' height is passed along via the _BodyBoxConstraints parameter. +// The constraints parameter is constructed in_ScaffoldLayout.performLayout(). +class _BodyBuilder extends StatelessWidget { + const _BodyBuilder({ + required this.extendBody, + required this.extendBodyBehindAppBar, + required this.body, + }); + + final Widget body; + final bool extendBody; + final bool extendBodyBehindAppBar; + + @override + Widget build(BuildContext context) { + if (!extendBody && !extendBodyBehindAppBar) { + return body; + } + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final _BodyBoxConstraints bodyConstraints = + constraints as _BodyBoxConstraints; + final MediaQueryData metrics = MediaQuery.of(context); + + final double bottom = extendBody + ? math.max( + metrics.padding.bottom, + bodyConstraints.bottomWidgetsHeight, + ) + : metrics.padding.bottom; + + final double top = extendBodyBehindAppBar + ? math.max( + metrics.padding.top, + bodyConstraints.appBarHeight + + bodyConstraints.materialBannerHeight, + ) + : metrics.padding.top; + + return MediaQuery( + data: metrics.copyWith( + padding: metrics.padding.copyWith(top: top, bottom: bottom), + ), + child: body, + ); + }, + ); + } +} + +class _ScaffoldLayout extends MultiChildLayoutDelegate { + _ScaffoldLayout({ + required this.minInsets, + required this.minViewPadding, + required this.textDirection, + required this.geometryNotifier, + // for floating action button + required this.previousFloatingActionButtonLocation, + required this.currentFloatingActionButtonLocation, + required this.floatingActionButtonMoveAnimationProgress, + required this.floatingActionButtonMotionAnimator, + required this.isSnackBarFloating, + required this.snackBarWidth, + required this.extendBody, + required this.extendBodyBehindAppBar, + required this.extendBodyBehindMaterialBanner, + }); + + final bool extendBody; + final bool extendBodyBehindAppBar; + final EdgeInsets minInsets; + final EdgeInsets minViewPadding; + final TextDirection textDirection; + final _ScaffoldGeometryNotifier geometryNotifier; + + final FloatingActionButtonLocation previousFloatingActionButtonLocation; + final FloatingActionButtonLocation currentFloatingActionButtonLocation; + final double floatingActionButtonMoveAnimationProgress; + final FloatingActionButtonAnimator floatingActionButtonMotionAnimator; + + final bool isSnackBarFloating; + final double? snackBarWidth; + + final bool extendBodyBehindMaterialBanner; + + @override + void performLayout(Size size) { + final BoxConstraints looseConstraints = BoxConstraints.loose(size); + + // This part of the layout has the same effect as putting the app bar and + // body in a column and making the body flexible. What's different is that + // in this case the app bar appears _after_ the body in the stacking order, + // so the app bar's shadow is drawn on top of the body. + + final BoxConstraints fullWidthConstraints = looseConstraints.tighten( + width: size.width, + ); + final double bottom = size.height; + double contentTop = 0.0; + double bottomWidgetsHeight = 0.0; + double appBarHeight = 0.0; + + if (hasChild(_ScaffoldSlot.appBar)) { + appBarHeight = layoutChild( + _ScaffoldSlot.appBar, + fullWidthConstraints, + ).height; + contentTop = extendBodyBehindAppBar ? 0.0 : appBarHeight; + positionChild(_ScaffoldSlot.appBar, Offset.zero); + } + + double? bottomNavigationBarTop; + if (hasChild(_ScaffoldSlot.bottomNavigationBar)) { + final double bottomNavigationBarHeight = layoutChild( + _ScaffoldSlot.bottomNavigationBar, + fullWidthConstraints, + ).height; + bottomWidgetsHeight += bottomNavigationBarHeight; + bottomNavigationBarTop = math.max(0.0, bottom - bottomWidgetsHeight); + positionChild( + _ScaffoldSlot.bottomNavigationBar, + Offset(0.0, bottomNavigationBarTop), + ); + } + + if (hasChild(_ScaffoldSlot.persistentFooter)) { + final BoxConstraints footerConstraints = BoxConstraints( + maxWidth: fullWidthConstraints.maxWidth, + maxHeight: math.max(0.0, bottom - bottomWidgetsHeight - contentTop), + ); + final double persistentFooterHeight = layoutChild( + _ScaffoldSlot.persistentFooter, + footerConstraints, + ).height; + bottomWidgetsHeight += persistentFooterHeight; + positionChild( + _ScaffoldSlot.persistentFooter, + Offset(0.0, math.max(0.0, bottom - bottomWidgetsHeight)), + ); + } + + Size materialBannerSize = Size.zero; + if (hasChild(_ScaffoldSlot.materialBanner)) { + materialBannerSize = layoutChild( + _ScaffoldSlot.materialBanner, + fullWidthConstraints, + ); + positionChild(_ScaffoldSlot.materialBanner, Offset(0.0, appBarHeight)); + + // Push content down only if elevation is 0. + if (!extendBodyBehindMaterialBanner) { + contentTop += materialBannerSize.height; + } + } + + // Set the content bottom to account for the greater of the height of any + // bottom-anchored material widgets or of the keyboard or other + // bottom-anchored system UI. + final double contentBottom = math.max( + 0.0, + bottom - math.max(minInsets.bottom, bottomWidgetsHeight), + ); + + if (hasChild(_ScaffoldSlot.body)) { + double bodyMaxHeight = math.max(0.0, contentBottom - contentTop); + + // When extendBody is true, the body is visible underneath the bottom widgets. + // This does not apply when the area is obscured by the device keyboard. + if (extendBody && minInsets.bottom <= bottomWidgetsHeight) { + bodyMaxHeight += bottomWidgetsHeight; + bodyMaxHeight = clampDouble( + bodyMaxHeight, + 0.0, + looseConstraints.maxHeight - contentTop, + ); + assert( + bodyMaxHeight <= + math.max(0.0, looseConstraints.maxHeight - contentTop), + ); + } else { + bottomWidgetsHeight = 0.0; + } + + final BoxConstraints bodyConstraints = _BodyBoxConstraints( + maxWidth: fullWidthConstraints.maxWidth, + maxHeight: bodyMaxHeight, + materialBannerHeight: materialBannerSize.height, + bottomWidgetsHeight: bottomWidgetsHeight, + appBarHeight: appBarHeight, + ); + layoutChild(_ScaffoldSlot.body, bodyConstraints); + positionChild(_ScaffoldSlot.body, Offset(0.0, contentTop)); + } + + // The BottomSheet and the SnackBar are anchored to the bottom of the parent, + // they're as wide as the parent and are given their intrinsic height. The + // only difference is that SnackBar appears on the top side of the + // BottomNavigationBar while the BottomSheet is stacked on top of it. + // + // If all three elements are present then either the center of the FAB straddles + // the top edge of the BottomSheet or the bottom of the FAB is + // kFloatingActionButtonMargin above the SnackBar, whichever puts the FAB + // the farthest above the bottom of the parent. If only the FAB is has a + // non-zero height then it's inset from the parent's right and bottom edges + // by kFloatingActionButtonMargin. + + Size bottomSheetSize = Size.zero; + Size snackBarSize = Size.zero; + if (hasChild(_ScaffoldSlot.bodyScrim)) { + final BoxConstraints bottomSheetScrimConstraints = BoxConstraints( + maxWidth: fullWidthConstraints.maxWidth, + maxHeight: contentBottom, + ); + layoutChild(_ScaffoldSlot.bodyScrim, bottomSheetScrimConstraints); + positionChild(_ScaffoldSlot.bodyScrim, Offset.zero); + } + + // Set the size of the SnackBar early if the behavior is fixed so + // the FAB can be positioned correctly. + if (hasChild(_ScaffoldSlot.snackBar) && !isSnackBarFloating) { + snackBarSize = layoutChild(_ScaffoldSlot.snackBar, fullWidthConstraints); + } + + if (hasChild(_ScaffoldSlot.bottomSheet)) { + final BoxConstraints bottomSheetConstraints = BoxConstraints( + maxWidth: fullWidthConstraints.maxWidth, + maxHeight: math.max(0.0, contentBottom - contentTop), + ); + bottomSheetSize = layoutChild( + _ScaffoldSlot.bottomSheet, + bottomSheetConstraints, + ); + positionChild( + _ScaffoldSlot.bottomSheet, + Offset( + (size.width - bottomSheetSize.width) / 2.0, + contentBottom - bottomSheetSize.height, + ), + ); + } + + late Rect floatingActionButtonRect; + if (hasChild(_ScaffoldSlot.floatingActionButton)) { + final Size fabSize = layoutChild( + _ScaffoldSlot.floatingActionButton, + looseConstraints, + ); + + // To account for the FAB position being changed, we'll animate between + // the old and new positions. + final ScaffoldPrelayoutGeometry + currentGeometry = ScaffoldPrelayoutGeometry( + bottomSheetSize: bottomSheetSize, + contentBottom: contentBottom, + + /// [appBarHeight] should be used instead of [contentTop] because + /// ScaffoldPrelayoutGeometry.contentTop must not be affected by [extendBodyBehindAppBar]. + contentTop: appBarHeight, + floatingActionButtonSize: fabSize, + minInsets: minInsets, + scaffoldSize: size, + snackBarSize: snackBarSize, + materialBannerSize: materialBannerSize, + textDirection: textDirection, + minViewPadding: minViewPadding, + ); + final Offset currentFabOffset = currentFloatingActionButtonLocation + .getOffset( + currentGeometry, + ); + final Offset previousFabOffset = previousFloatingActionButtonLocation + .getOffset( + currentGeometry, + ); + final Offset fabOffset = floatingActionButtonMotionAnimator.getOffset( + begin: previousFabOffset, + end: currentFabOffset, + progress: floatingActionButtonMoveAnimationProgress, + ); + positionChild(_ScaffoldSlot.floatingActionButton, fabOffset); + floatingActionButtonRect = fabOffset & fabSize; + } + + if (hasChild(_ScaffoldSlot.snackBar)) { + final bool hasCustomWidth = + snackBarWidth != null && snackBarWidth! < size.width; + if (snackBarSize == Size.zero) { + snackBarSize = layoutChild( + _ScaffoldSlot.snackBar, + hasCustomWidth ? looseConstraints : fullWidthConstraints, + ); + } + + final double snackBarYOffsetBase; + final bool showAboveFab = switch (currentFloatingActionButtonLocation) { + FloatingActionButtonLocation.startTop || + FloatingActionButtonLocation.centerTop || + FloatingActionButtonLocation.endTop || + FloatingActionButtonLocation.miniStartTop || + FloatingActionButtonLocation.miniCenterTop || + FloatingActionButtonLocation.miniEndTop => false, + FloatingActionButtonLocation.startDocked || + FloatingActionButtonLocation.startFloat || + FloatingActionButtonLocation.centerDocked || + FloatingActionButtonLocation.centerFloat || + FloatingActionButtonLocation.endContained || + FloatingActionButtonLocation.endDocked || + FloatingActionButtonLocation.endFloat || + FloatingActionButtonLocation.miniStartDocked || + FloatingActionButtonLocation.miniStartFloat || + FloatingActionButtonLocation.miniCenterDocked || + FloatingActionButtonLocation.miniCenterFloat || + FloatingActionButtonLocation.miniEndDocked || + FloatingActionButtonLocation.miniEndFloat => true, + FloatingActionButtonLocation() => true, + }; + if (floatingActionButtonRect.size != Size.zero && + isSnackBarFloating && + showAboveFab) { + if (bottomNavigationBarTop != null) { + snackBarYOffsetBase = math.min( + bottomNavigationBarTop, + floatingActionButtonRect.top, + ); + } else { + snackBarYOffsetBase = floatingActionButtonRect.top; + } + } else { + // SnackBarBehavior.fixed applies a SafeArea automatically. + // SnackBarBehavior.floating does not since the positioning is affected + // if there is a FloatingActionButton (see condition above). If there is + // no FAB, make sure we account for safe space when the SnackBar is + // floating. + final double safeYOffsetBase = size.height - minViewPadding.bottom; + snackBarYOffsetBase = isSnackBarFloating + ? math.min(contentBottom, safeYOffsetBase) + : contentBottom; + } + + final double xOffset = hasCustomWidth + ? (size.width - snackBarWidth!) / 2 + : 0.0; + positionChild( + _ScaffoldSlot.snackBar, + Offset(xOffset, snackBarYOffsetBase - snackBarSize.height), + ); + + assert(() { + // Whether a floating SnackBar has been offset too high. + // + // To improve the developer experience, this assert is done after the call to positionChild. + // if we assert sooner the SnackBar is visible because its defaults position is (0,0) and + // it can cause confusion to the user as the error message states that the SnackBar is off screen. + if (isSnackBarFloating) { + final bool snackBarVisible = + (snackBarYOffsetBase - snackBarSize.height) >= 0; + if (!snackBarVisible) { + throw FlutterError.fromParts([ + ErrorSummary('Floating SnackBar presented off screen.'), + ErrorDescription( + 'A SnackBar with behavior property set to SnackBarBehavior.floating is fully ' + 'or partially off screen because some or all the widgets provided to ' + 'Scaffold.floatingActionButton, Scaffold.persistentFooterButtons and ' + 'Scaffold.bottomNavigationBar take up too much vertical space.\n', + ), + ErrorHint( + 'Consider constraining the size of these widgets to allow room for the SnackBar to be visible.', + ), + ]); + } + } + return true; + }()); + } + + if (hasChild(_ScaffoldSlot.statusBar)) { + layoutChild( + _ScaffoldSlot.statusBar, + fullWidthConstraints.tighten(height: minInsets.top), + ); + positionChild(_ScaffoldSlot.statusBar, Offset.zero); + } + + if (hasChild(_ScaffoldSlot.drawer)) { + layoutChild(_ScaffoldSlot.drawer, BoxConstraints.tight(size)); + positionChild(_ScaffoldSlot.drawer, Offset.zero); + } + + if (hasChild(_ScaffoldSlot.endDrawer)) { + layoutChild(_ScaffoldSlot.endDrawer, BoxConstraints.tight(size)); + positionChild(_ScaffoldSlot.endDrawer, Offset.zero); + } + + geometryNotifier._updateWith( + bottomNavigationBarTop: bottomNavigationBarTop, + floatingActionButtonArea: floatingActionButtonRect, + ); + } + + @override + bool shouldRelayout(_ScaffoldLayout oldDelegate) { + return oldDelegate.minInsets != minInsets || + oldDelegate.minViewPadding != minViewPadding || + oldDelegate.textDirection != textDirection || + oldDelegate.floatingActionButtonMoveAnimationProgress != + floatingActionButtonMoveAnimationProgress || + oldDelegate.previousFloatingActionButtonLocation != + previousFloatingActionButtonLocation || + oldDelegate.currentFloatingActionButtonLocation != + currentFloatingActionButtonLocation || + oldDelegate.extendBody != extendBody || + oldDelegate.extendBodyBehindAppBar != extendBodyBehindAppBar; + } +} + +/// Handler for scale and rotation animations in the [FloatingActionButton]. +/// +/// Currently, there are two types of [FloatingActionButton] animations: +/// +/// * Entrance/Exit animations, which this widget triggers +/// when the [FloatingActionButton] is added, updated, or removed. +/// * Motion animations, which are triggered by the [Scaffold] +/// when its [FloatingActionButtonLocation] is updated. +class _FloatingActionButtonTransition extends StatefulWidget { + const _FloatingActionButtonTransition({ + required this.child, + required this.fabMoveAnimation, + required this.fabMotionAnimator, + required this.geometryNotifier, + required this.currentController, + }); + + final Widget? child; + final Animation fabMoveAnimation; + final FloatingActionButtonAnimator fabMotionAnimator; + final _ScaffoldGeometryNotifier geometryNotifier; + + /// Controls the current child widget.child as it exits. + final AnimationController currentController; + + @override + _FloatingActionButtonTransitionState createState() => + _FloatingActionButtonTransitionState(); +} + +class _FloatingActionButtonTransitionState + extends State<_FloatingActionButtonTransition> + with TickerProviderStateMixin { + // The animations applied to the Floating Action Button when it is entering or exiting. + // Controls the previous widget.child as it exits. + late AnimationController _previousController; + CurvedAnimation? _previousExitScaleAnimation; + CurvedAnimation? _previousExitRotationCurvedAnimation; + CurvedAnimation? _currentEntranceScaleAnimation; + late Animation _previousScaleAnimation; + late TrainHoppingAnimation _previousRotationAnimation; + // The animations to run, considering the widget's fabMoveAnimation and the current/previous entrance/exit animations. + late Animation _currentScaleAnimation; + late Animation _extendedCurrentScaleAnimation; + late TrainHoppingAnimation _currentRotationAnimation; + Widget? _previousChild; + + @override + void initState() { + super.initState(); + + _previousController = AnimationController( + duration: kFloatingActionButtonSegue, + vsync: this, + )..addStatusListener(_handlePreviousAnimationStatusChanged); + _updateAnimations(); + + if (widget.child != null) { + // If we start out with a child, have the child appear fully visible instead + // of animating in. + widget.currentController.value = 1.0; + } else { + // If we start without a child we update the geometry object with a + // floating action button scale of 0, as it is not showing on the screen. + _updateGeometryScale(0.0); + } + } + + @override + void dispose() { + _previousController.dispose(); + _previousExitScaleAnimation?.dispose(); + _previousExitRotationCurvedAnimation?.dispose(); + _currentEntranceScaleAnimation?.dispose(); + _disposeAnimations(); + super.dispose(); + } + + @override + void didUpdateWidget(_FloatingActionButtonTransition oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.fabMotionAnimator != widget.fabMotionAnimator || + oldWidget.fabMoveAnimation != widget.fabMoveAnimation) { + _disposeAnimations(); + // Get the right scale and rotation animations to use for this widget. + _updateAnimations(); + } + final bool oldChildIsNull = oldWidget.child == null; + final bool newChildIsNull = widget.child == null; + if (oldChildIsNull == newChildIsNull && + oldWidget.child?.key == widget.child?.key) { + return; + } + if (_previousController.isDismissed) { + final double currentValue = widget.currentController.value; + if (currentValue == 0.0 || oldWidget.child == null) { + // The current child hasn't started its entrance animation yet. We can + // just skip directly to the new child's entrance. + _previousChild = null; + if (widget.child != null) { + widget.currentController.forward(); + } + } else { + // Otherwise, we need to copy the state from the current controller to + // the previous controller and run an exit animation for the previous + // widget before running the entrance animation for the new child. + _previousChild = oldWidget.child; + _previousController + ..value = currentValue + ..reverse(); + widget.currentController.value = 0.0; + } + } + } + + static final Animatable _entranceTurnTween = Tween( + begin: 1.0 - kFloatingActionButtonTurnInterval, + end: 1.0, + ).chain(CurveTween(curve: Curves.easeIn)); + + void _disposeAnimations() { + _previousRotationAnimation.dispose(); + _currentRotationAnimation.dispose(); + } + + void _updateAnimations() { + _previousExitScaleAnimation?.dispose(); + // Get the animations for exit and entrance. + _previousExitScaleAnimation = CurvedAnimation( + parent: _previousController, + curve: Curves.easeIn, + ); + _previousExitRotationCurvedAnimation?.dispose(); + _previousExitRotationCurvedAnimation = CurvedAnimation( + parent: _previousController, + curve: Curves.easeIn, + ); + + final Animation previousExitRotationAnimation = Tween( + begin: 1.0, + end: 1.0, + ).animate(_previousExitRotationCurvedAnimation!); + + _currentEntranceScaleAnimation?.dispose(); + _currentEntranceScaleAnimation = CurvedAnimation( + parent: widget.currentController, + curve: Curves.easeIn, + ); + final Animation currentEntranceRotationAnimation = widget + .currentController + .drive( + _entranceTurnTween, + ); + + // Get the animations for when the FAB is moving. + final Animation moveScaleAnimation = widget.fabMotionAnimator + .getScaleAnimation( + parent: widget.fabMoveAnimation, + ); + final Animation moveRotationAnimation = widget.fabMotionAnimator + .getRotationAnimation( + parent: widget.fabMoveAnimation, + ); + + // Aggregate the animations. + if (widget.fabMotionAnimator == FloatingActionButtonAnimator.noAnimation) { + _previousScaleAnimation = moveScaleAnimation; + _currentScaleAnimation = moveScaleAnimation; + _previousRotationAnimation = TrainHoppingAnimation( + moveRotationAnimation, + null, + ); + _currentRotationAnimation = TrainHoppingAnimation( + moveRotationAnimation, + null, + ); + } else { + _previousScaleAnimation = AnimationMin( + moveScaleAnimation, + _previousExitScaleAnimation!, + ); + _currentScaleAnimation = AnimationMin( + moveScaleAnimation, + _currentEntranceScaleAnimation!, + ); + _previousRotationAnimation = TrainHoppingAnimation( + previousExitRotationAnimation, + moveRotationAnimation, + ); + _currentRotationAnimation = TrainHoppingAnimation( + currentEntranceRotationAnimation, + moveRotationAnimation, + ); + } + + _extendedCurrentScaleAnimation = _currentScaleAnimation.drive( + CurveTween(curve: const Interval(0.0, 0.1)), + ); + _currentScaleAnimation.addListener(_onProgressChanged); + _previousScaleAnimation.addListener(_onProgressChanged); + } + + void _handlePreviousAnimationStatusChanged(AnimationStatus status) { + setState(() { + if (widget.child != null && status.isDismissed) { + assert(widget.currentController.isDismissed); + widget.currentController.forward(); + } + }); + } + + bool _isExtendedFloatingActionButton(Widget? widget) { + return widget is FloatingActionButton && widget.isExtended; + } + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.centerRight, + children: [ + if (!_previousController.isDismissed) + if (_isExtendedFloatingActionButton(_previousChild)) + FadeTransition( + opacity: _previousScaleAnimation, + child: _previousChild, + ) + else + ScaleTransition( + scale: _previousScaleAnimation, + child: RotationTransition( + turns: _previousRotationAnimation, + child: _previousChild, + ), + ), + if (_isExtendedFloatingActionButton(widget.child)) + ScaleTransition( + scale: _extendedCurrentScaleAnimation, + child: FadeTransition( + opacity: _currentScaleAnimation, + child: widget.child, + ), + ) + else + ScaleTransition( + scale: _currentScaleAnimation, + child: RotationTransition( + turns: _currentRotationAnimation, + child: widget.child, + ), + ), + ], + ); + } + + void _onProgressChanged() { + _updateGeometryScale( + math.max(_previousScaleAnimation.value, _currentScaleAnimation.value), + ); + } + + void _updateGeometryScale(double scale) { + widget.geometryNotifier._updateWith(floatingActionButtonScale: scale); + } +} + +/// Implements the basic Material Design visual layout structure. +/// +/// This class provides APIs for showing drawers and bottom sheets. +/// +/// To display a persistent bottom sheet, obtain the +/// [ScaffoldState] for the current [BuildContext] via [Scaffold.of] and use the +/// [ScaffoldState.showBottomSheet] function. +/// +/// {@tool dartpad} +/// This example shows a [Scaffold] with a [body] and [FloatingActionButton]. +/// The [body] is a [Text] placed in a [Center] in order to center the text +/// within the [Scaffold]. The [FloatingActionButton] is connected to a +/// callback that increments a counter. +/// +/// ** See code in examples/api/lib/material/scaffold/scaffold.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows a [Scaffold] with a blueGrey [backgroundColor], [body] +/// and [FloatingActionButton]. The [body] is a [Text] placed in a [Center] in +/// order to center the text within the [Scaffold]. The [FloatingActionButton] +/// is connected to a callback that increments a counter. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/scaffold_background_color.png) +/// +/// ** See code in examples/api/lib/material/scaffold/scaffold.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows a [Scaffold] with an [AppBar], a [BottomAppBar] and a +/// [FloatingActionButton]. The [body] is a [Text] placed in a [Center] in order +/// to center the text within the [Scaffold]. The [FloatingActionButton] is +/// centered and docked within the [BottomAppBar] using +/// [FloatingActionButtonLocation.centerDocked]. The [FloatingActionButton] is +/// connected to a callback that increments a counter. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/material/scaffold_bottom_app_bar.png) +/// +/// ** See code in examples/api/lib/material/scaffold/scaffold.2.dart ** +/// {@end-tool} +/// +/// ## Scaffold layout, the keyboard, and display "notches" +/// +/// The scaffold will expand to fill the available space. That usually +/// means that it will occupy its entire window or device screen. When +/// the device's keyboard appears the Scaffold's ancestor [MediaQuery] +/// widget's [MediaQueryData.viewInsets] changes and the Scaffold will +/// be rebuilt. By default the scaffold's [body] is resized to make +/// room for the keyboard. To prevent the resize set +/// [resizeToAvoidBottomInset] to false. In either case the focused +/// widget will be scrolled into view if it's within a scrollable +/// container. +/// +/// The [MediaQueryData.padding] value defines areas that might +/// not be completely visible, like the display "notch" on the iPhone +/// X. The scaffold's [body] is not inset by this padding value +/// although an [appBar] or [bottomNavigationBar] will typically +/// cause the body to avoid the padding. The [SafeArea] +/// widget can be used within the scaffold's body to avoid areas +/// like display notches. +/// +/// ## Floating action button with a draggable scrollable bottom sheet +/// +/// If [Scaffold.bottomSheet] is a [DraggableScrollableSheet], +/// [Scaffold.floatingActionButton] is set, and the bottom sheet is dragged to +/// cover greater than 70% of the Scaffold's height, two things happen in parallel: +/// +/// * Scaffold starts to show scrim (see [ScaffoldState.showBodyScrim]), and +/// * [Scaffold.floatingActionButton] is scaled down through an animation with a [Curves.easeIn], and +/// disappears when the bottom sheet covers the entire Scaffold. +/// +/// And as soon as the bottom sheet is dragged down to cover less than 70% of the [Scaffold], the scrim +/// disappears and [Scaffold.floatingActionButton] animates back to its normal size. +/// +/// ## Troubleshooting +/// +/// ### Nested Scaffolds +/// +/// The Scaffold is designed to be a top level container for +/// a [MaterialApp]. This means that adding a Scaffold +/// to each route on a Material app will provide the app with +/// Material's basic visual layout structure. +/// +/// It is typically not necessary to nest Scaffolds. For example, in a +/// tabbed UI, where the [bottomNavigationBar] is a [TabBar] +/// and the body is a [TabBarView], you might be tempted to make each tab bar +/// view a scaffold with a differently titled AppBar. Rather, it would be +/// better to add a listener to the [TabController] that updates the +/// AppBar +/// +/// {@tool snippet} +/// Add a listener to the app's tab controller so that the [AppBar] title of the +/// app's one and only scaffold is reset each time a new tab is selected. +/// +/// ```dart +/// TabController(vsync: tickerProvider, length: tabCount)..addListener(() { +/// if (!tabController.indexIsChanging) { +/// setState(() { +/// // Rebuild the enclosing scaffold with a new AppBar title +/// appBarTitle = 'Tab ${tabController.index}'; +/// }); +/// } +/// }) +/// ``` +/// {@end-tool} +/// +/// Although there are some use cases, like a presentation app that +/// shows embedded flutter content, where nested scaffolds are +/// appropriate, it's best to avoid nesting scaffolds. +/// +/// See also: +/// +/// * [AppBar], which is a horizontal bar typically shown at the top of an app +/// using the [appBar] property. +/// * [BottomAppBar], which is a horizontal bar typically shown at the bottom +/// of an app using the [bottomNavigationBar] property. +/// * [FloatingActionButton], which is a circular button typically shown in the +/// bottom right corner of the app using the [floatingActionButton] property. +/// * [Drawer], which is a vertical panel that is typically displayed to the +/// left of the body (and often hidden on phones) using the [drawer] +/// property. +/// * [BottomNavigationBar], which is a horizontal array of buttons typically +/// shown along the bottom of the app using the [bottomNavigationBar] +/// property. +/// * [BottomSheet], which is an overlay typically shown near the bottom of the +/// app. A bottom sheet can either be persistent, in which case it is shown +/// using the [ScaffoldState.showBottomSheet] method, or modal, in which case +/// it is shown using the [showModalBottomSheet] function. +/// * [SnackBar], which is a lightweight message with an optional action which +/// briefly displays at the bottom of the screen. Use the +/// [ScaffoldMessengerState.showSnackBar] method to show snack bars. +/// * [MaterialBanner], which displays an important, succinct message, at the +/// top of the screen, below the app bar. Use the +/// [ScaffoldMessengerState.showMaterialBanner] method to show material banners. +/// * [ScaffoldState], which is the state associated with this widget. +/// * +/// * Cookbook: [Add a Drawer to a screen](https://docs.flutter.dev/cookbook/design/drawer) +class Scaffold extends StatefulWidget implements material.Scaffold { + /// Creates a visual scaffold for Material Design widgets. + const Scaffold({ + super.key, + this.appBar, + this.body, + this.floatingActionButton, + this.floatingActionButtonLocation, + this.floatingActionButtonAnimator, + this.persistentFooterButtons, + this.persistentFooterAlignment = AlignmentDirectional.centerEnd, + this.persistentFooterDecoration, + this.drawer, + this.onDrawerChanged, + this.endDrawer, + this.onEndDrawerChanged, + this.bottomNavigationBar, + this.bottomSheet, + this.backgroundColor, + this.resizeToAvoidBottomInset, + this.primary = true, + this.drawerDragStartBehavior = DragStartBehavior.start, + this.extendBody = false, + this.drawerBarrierDismissible = true, + this.extendBodyBehindAppBar = false, + this.drawerScrimColor, + this.bottomSheetScrimBuilder = _defaultBottomSheetScrimBuilder, + this.drawerEdgeDragWidth, + this.drawerEnableOpenDragGesture = true, + this.endDrawerEnableOpenDragGesture = true, + this.restorationId, + }); + + /// If true, and [bottomNavigationBar] or [persistentFooterButtons] + /// is specified, then the [body] extends to the bottom of the Scaffold, + /// instead of only extending to the top of the [bottomNavigationBar] + /// or the [persistentFooterButtons]. + /// + /// If true, a [MediaQuery] widget whose bottom padding matches the height + /// of the [bottomNavigationBar] will be added above the scaffold's [body]. + /// + /// This property is often useful when the [bottomNavigationBar] has + /// a non-rectangular shape, like [CircularNotchedRectangle], which + /// adds a [FloatingActionButton] sized notch to the top edge of the bar. + /// In this case specifying `extendBody: true` ensures that scaffold's + /// body will be visible through the bottom navigation bar's notch. + /// + /// See also: + /// + /// * [extendBodyBehindAppBar], which extends the height of the body + /// to the top of the scaffold. + final bool extendBody; + + /// Whether the drawer can be dismissed by tapping on the barrier. + /// + /// If false, and a [drawer] is specified, then the barrier behind the drawer + /// will not respond to a tap event and thus remains open. + /// + /// Defaults to true, in which case the drawer will close upon the user tapping on the barrier. + final bool drawerBarrierDismissible; + + /// If true, and an [appBar] is specified, then the height of the [body] is + /// extended to include the height of the app bar and the top of the body + /// is aligned with the top of the app bar. + /// + /// This is useful if the app bar's [AppBar.backgroundColor] is not + /// completely opaque. + /// + /// This property is false by default. + /// + /// See also: + /// + /// * [extendBody], which extends the height of the body to the bottom + /// of the scaffold. + final bool extendBodyBehindAppBar; + + /// An app bar to display at the top of the scaffold. + final PreferredSizeWidget? appBar; + + /// The primary content of the scaffold. + /// + /// Displayed below the [appBar], above the bottom of the ambient + /// [MediaQuery]'s [MediaQueryData.viewInsets], and behind the + /// [floatingActionButton] and [drawer]. If [resizeToAvoidBottomInset] is + /// false then the body is not resized when the onscreen keyboard appears, + /// i.e. it is not inset by `viewInsets.bottom`. + /// + /// The widget in the body of the scaffold is positioned at the top-left of + /// the available space between the app bar and the bottom of the scaffold. To + /// center this widget instead, consider putting it in a [Center] widget and + /// having that be the body. To expand this widget instead, consider + /// putting it in a [SizedBox.expand]. + /// + /// If you have a column of widgets that should normally fit on the screen, + /// but may overflow and would in such cases need to scroll, consider using a + /// [ListView] as the body of the scaffold. This is also a good choice for + /// the case where your body is a scrollable list. + final Widget? body; + + /// A button displayed floating above [body], in the bottom right corner. + /// + /// Typically a [FloatingActionButton]. + final Widget? floatingActionButton; + + /// Responsible for determining where the [floatingActionButton] should go. + /// + /// If null, the [ScaffoldState] will use the default location, [FloatingActionButtonLocation.endFloat]. + final FloatingActionButtonLocation? floatingActionButtonLocation; + + /// Animator to move the [floatingActionButton] to a new [floatingActionButtonLocation]. + /// + /// If null, the [ScaffoldState] will use the default animator, [FloatingActionButtonAnimator.scaling]. + final FloatingActionButtonAnimator? floatingActionButtonAnimator; + + /// A set of buttons that are displayed at the bottom of the scaffold. + /// + /// Typically this is a list of [TextButton] widgets. These buttons are + /// persistently visible, even if the [body] of the scaffold scrolls. + /// + /// These widgets will be wrapped in an [OverflowBar]. + /// + /// The [persistentFooterButtons] are rendered above the + /// [bottomNavigationBar] but below the [body]. + final List? persistentFooterButtons; + + /// The alignment of the [persistentFooterButtons] inside the [OverflowBar]. + /// + /// Defaults to [AlignmentDirectional.centerEnd]. + final AlignmentDirectional persistentFooterAlignment; + + /// Decoration for the container that holds the [persistentFooterButtons]. + /// + /// By default, this container has a top border with a width of 1.0, created by + /// [Divider.createBorderSide]. + /// + /// See also: + /// + /// * [persistentFooterButtons], which defines the buttons to show in the footer. + /// * [persistentFooterAlignment], which defines the alignment of the footer buttons. + final BoxDecoration? persistentFooterDecoration; + + /// A panel displayed to the side of the [body], often hidden on mobile + /// devices. Swipes in from either left-to-right ([TextDirection.ltr]) or + /// right-to-left ([TextDirection.rtl]) + /// + /// Typically a [Drawer]. + /// + /// To open the drawer, use the [ScaffoldState.openDrawer] function. + /// + /// To close the drawer, use either [ScaffoldState.closeDrawer], [Navigator.pop] + /// or press the escape key on the keyboard. + /// + /// {@tool dartpad} + /// To disable the drawer edge swipe on mobile, set the + /// [Scaffold.drawerEnableOpenDragGesture] to false. Then, use + /// [ScaffoldState.openDrawer] to open the drawer and [Navigator.pop] to close + /// it. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold.drawer.0.dart ** + /// {@end-tool} + final Widget? drawer; + + /// Optional callback that is called when the [Scaffold.drawer] is opened or closed. + final DrawerCallback? onDrawerChanged; + + /// A panel displayed to the side of the [body], often hidden on mobile + /// devices. Swipes in from right-to-left ([TextDirection.ltr]) or + /// left-to-right ([TextDirection.rtl]) + /// + /// Typically a [Drawer]. + /// + /// To open the drawer, use the [ScaffoldState.openEndDrawer] function. + /// + /// To close the drawer, use either [ScaffoldState.closeEndDrawer], [Navigator.pop] + /// or press the escape key on the keyboard. + /// + /// {@tool dartpad} + /// To disable the drawer edge swipe, set the + /// [Scaffold.endDrawerEnableOpenDragGesture] to false. Then, use + /// [ScaffoldState.openEndDrawer] to open the drawer and [Navigator.pop] to + /// close it. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold.end_drawer.0.dart ** + /// {@end-tool} + final Widget? endDrawer; + + /// Optional callback that is called when the [Scaffold.endDrawer] is opened or closed. + final DrawerCallback? onEndDrawerChanged; + + /// The color to use for the scrim that obscures primary content while a drawer is open. + /// + /// If this is null, then [DrawerThemeData.scrimColor] is used. If that + /// is also null, then it defaults to [Colors.black54]. + final Color? drawerScrimColor; + + /// A builder for the widget that obscures primary content while a bottom sheet is open. + /// + /// The builder receives the current [BuildContext] and an [Animation] as parameters. + /// The [Animation] ranges from 0.0 to 1.0 based on how much the bottom sheet covers the screen. + /// A value of 0.0 represents when the bottom sheet covers 70% of the screen, + /// and 1.0 represents when the bottom sheet fully covers the screen. + /// + /// If this is null, then a non-dismissable [ModalBarrier] with [Colors.black] is used. The + /// barrier is animated to fade in and out as the bottom sheet is opened and closed. + /// + /// If the builder returns null, then no scrim is shown. + final Widget? Function(BuildContext, Animation) + bottomSheetScrimBuilder; + + /// The color of the [Material] widget that underlies the entire Scaffold. + /// + /// The theme's [ThemeData.scaffoldBackgroundColor] by default. + final Color? backgroundColor; + + /// A bottom navigation bar to display at the bottom of the scaffold. + /// + /// Snack bars slide from underneath the bottom navigation bar while bottom + /// sheets are stacked on top. + /// + /// The [bottomNavigationBar] is rendered below the [persistentFooterButtons] + /// and the [body]. + final Widget? bottomNavigationBar; + + /// The persistent bottom sheet to display. + /// + /// A persistent bottom sheet shows information that supplements the primary + /// content of the app. A persistent bottom sheet remains visible even when + /// the user interacts with other parts of the app. + /// + /// A closely related widget is a modal bottom sheet, which is an alternative + /// to a menu or a dialog and prevents the user from interacting with the rest + /// of the app. Modal bottom sheets can be created and displayed with the + /// [showModalBottomSheet] function. + /// + /// Unlike the persistent bottom sheet displayed by [showBottomSheet] + /// this bottom sheet is not a [LocalHistoryEntry] and cannot be dismissed + /// with the scaffold appbar's back button. + /// + /// If a persistent bottom sheet created with [showBottomSheet] is already + /// visible, it must be closed before building the Scaffold with a new + /// [bottomSheet]. + /// + /// The value of [bottomSheet] can be any widget at all. It's unlikely to + /// actually be a [BottomSheet], which is used by the implementations of + /// [showBottomSheet] and [showModalBottomSheet]. Typically it's a widget + /// that includes [Material]. + /// + /// See also: + /// + /// * [showBottomSheet], which displays a bottom sheet as a route that can + /// be dismissed with the scaffold's back button. + /// * [showModalBottomSheet], which displays a modal bottom sheet. + /// * [BottomSheetThemeData], which can be used to customize the default + /// bottom sheet property values when using a [BottomSheet]. + final Widget? bottomSheet; + + /// If true the [body] and the scaffold's floating widgets should size + /// themselves to avoid the onscreen keyboard whose height is defined by the + /// ambient [MediaQuery]'s [MediaQueryData.viewInsets] `bottom` property. + /// + /// For example, if there is an onscreen keyboard displayed above the + /// scaffold, the body can be resized to avoid overlapping the keyboard, which + /// prevents widgets inside the body from being obscured by the keyboard. + /// + /// Defaults to true. + final bool? resizeToAvoidBottomInset; + + /// Whether this scaffold is being displayed at the top of the screen. + /// + /// If true then the height of the [appBar] will be extended by the height + /// of the screen's status bar, i.e. the top padding for [MediaQuery]. + /// + /// The default value of this property, like the default value of + /// [AppBar.primary], is true. + final bool primary; + + /// {@macro flutter.material.DrawerController.dragStartBehavior} + final DragStartBehavior drawerDragStartBehavior; + + /// The width of the area within which a horizontal swipe will open the + /// drawer. + /// + /// By default, the value used is 20.0 added to the padding edge of + /// `MediaQuery.paddingOf(context)` that corresponds to the surrounding + /// [TextDirection]. This ensures that the drag area for notched devices is + /// not obscured. For example, if `TextDirection.of(context)` is set to + /// [TextDirection.ltr], 20.0 will be added to + /// `MediaQuery.paddingOf(context).left`. + final double? drawerEdgeDragWidth; + + /// Determines if the [Scaffold.drawer] can be opened with a drag + /// gesture on mobile. + /// + /// On desktop platforms, the drawer is not draggable. + /// + /// By default, the drag gesture is enabled on mobile. + final bool drawerEnableOpenDragGesture; + + /// Determines if the [Scaffold.endDrawer] can be opened with a + /// gesture on mobile. + /// + /// On desktop platforms, the drawer is not draggable. + /// + /// By default, the drag gesture is enabled on mobile. + final bool endDrawerEnableOpenDragGesture; + + /// Restoration ID to save and restore the state of the [Scaffold]. + /// + /// If it is non-null, the scaffold will persist and restore whether the + /// [drawer] and [endDrawer] was open or closed. + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + /// + /// See also: + /// + /// * [RestorationManager], which explains how state restoration works in + /// Flutter. + final String? restorationId; + + /// Finds the [ScaffoldState] from the closest instance of this class that + /// encloses the given context. + /// + /// If no instance of this class encloses the given context, will cause an + /// assert in debug mode, and throw an exception in release mode. + /// + /// This method can be expensive (it walks the element tree). + /// + /// {@tool dartpad} + /// Typical usage of the [Scaffold.of] function is to call it from within the + /// `build` method of a child of a [Scaffold]. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold.of.0.dart ** + /// {@end-tool} + /// + /// {@tool dartpad} + /// When the [Scaffold] is actually created in the same `build` function, the + /// `context` argument to the `build` function can't be used to find the + /// [Scaffold] (since it's "above" the widget being returned in the widget + /// tree). In such cases, the following technique with a [Builder] can be used + /// to provide a new scope with a [BuildContext] that is "under" the + /// [Scaffold]: + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold.of.1.dart ** + /// {@end-tool} + /// + /// A more efficient solution is to split your build function into several + /// widgets. This introduces a new context from which you can obtain the + /// [Scaffold]. In this solution, you would have an outer widget that creates + /// the [Scaffold] populated by instances of your new inner widgets, and then + /// in these inner widgets you would use [Scaffold.of]. + /// + /// A less elegant but more expedient solution is assign a [GlobalKey] to the + /// [Scaffold], then use the `key.currentState` property to obtain the + /// [ScaffoldState] rather than using the [Scaffold.of] function. + /// + /// If there is no [Scaffold] in scope, then this will throw an exception. + /// To return null if there is no [Scaffold], use [maybeOf] instead. + static ScaffoldState of(BuildContext context) { + final ScaffoldState? result = context + .findAncestorStateOfType(); + if (result != null) { + return result; + } + throw FlutterError.fromParts([ + ErrorSummary( + 'Scaffold.of() called with a context that does not contain a Scaffold.', + ), + ErrorDescription( + 'No Scaffold ancestor could be found starting from the context that was passed to Scaffold.of(). ' + 'This usually happens when the context provided is from the same StatefulWidget as that ' + 'whose build function actually creates the Scaffold widget being sought.', + ), + ErrorHint( + 'There are several ways to avoid this problem. The simplest is to use a Builder to get a ' + 'context that is "under" the Scaffold. For an example of this, please see the ' + 'documentation for Scaffold.of():\n' + ' https://api.flutter.dev/flutter/material/Scaffold/of.html', + ), + ErrorHint( + 'A more efficient solution is to split your build function into several widgets. This ' + 'introduces a new context from which you can obtain the Scaffold. In this solution, ' + 'you would have an outer widget that creates the Scaffold populated by instances of ' + 'your new inner widgets, and then in these inner widgets you would use Scaffold.of().\n' + 'A less elegant but more expedient solution is assign a GlobalKey to the Scaffold, ' + 'then use the key.currentState property to obtain the ScaffoldState rather than ' + 'using the Scaffold.of() function.', + ), + context.describeElement('The context used was'), + ]); + } + + /// Finds the [ScaffoldState] from the closest instance of this class that + /// encloses the given context. + /// + /// If no instance of this class encloses the given context, will return null. + /// To throw an exception instead, use [of] instead of this function. + /// + /// This method can be expensive (it walks the element tree). + /// + /// See also: + /// + /// * [of], a similar function to this one that throws if no instance + /// encloses the given context. Also includes some sample code in its + /// documentation. + static ScaffoldState? maybeOf(BuildContext context) { + return context.findAncestorStateOfType(); + } + + /// Returns a [ValueListenable] for the [ScaffoldGeometry] for the closest + /// [Scaffold] ancestor of the given context. + /// + /// The [ValueListenable.value] is only available at paint time. + /// + /// Notifications are guaranteed to be sent before the first paint pass + /// with the new geometry, but there is no guarantee whether a build or + /// layout passes are going to happen between the notification and the next + /// paint pass. + /// + /// The closest [Scaffold] ancestor for the context might change, e.g when + /// an element is moved from one scaffold to another. For [StatefulWidget]s + /// using this listenable, a change of the [Scaffold] ancestor will + /// trigger a [State.didChangeDependencies]. + /// + /// A typical pattern for listening to the scaffold geometry would be to + /// call [Scaffold.geometryOf] in [State.didChangeDependencies], compare the + /// return value with the previous listenable, if it has changed, unregister + /// the listener, and register a listener to the new [ScaffoldGeometry] + /// listenable. + static ValueListenable geometryOf(BuildContext context) { + final _ScaffoldScope? scaffoldScope = context + .dependOnInheritedWidgetOfExactType<_ScaffoldScope>(); + if (scaffoldScope == null) { + throw FlutterError.fromParts([ + ErrorSummary( + 'Scaffold.geometryOf() called with a context that does not contain a Scaffold.', + ), + ErrorDescription( + 'This usually happens when the context provided is from the same StatefulWidget as that ' + 'whose build function actually creates the Scaffold widget being sought.', + ), + ErrorHint( + 'There are several ways to avoid this problem. The simplest is to use a Builder to get a ' + 'context that is "under" the Scaffold. For an example of this, please see the ' + 'documentation for Scaffold.of():\n' + ' https://api.flutter.dev/flutter/material/Scaffold/of.html', + ), + ErrorHint( + 'A more efficient solution is to split your build function into several widgets. This ' + 'introduces a new context from which you can obtain the Scaffold. In this solution, ' + 'you would have an outer widget that creates the Scaffold populated by instances of ' + 'your new inner widgets, and then in these inner widgets you would use Scaffold.geometryOf().', + ), + context.describeElement('The context used was'), + ]); + } + return scaffoldScope.geometryNotifier; + } + + /// Whether the Scaffold that most tightly encloses the given context has a + /// drawer. + /// + /// If this is being used during a build (for example to decide whether to + /// show an "open drawer" button), set the `registerForUpdates` argument to + /// true. This will then set up an [InheritedWidget] relationship with the + /// [Scaffold] so that the client widget gets rebuilt whenever the [hasDrawer] + /// value changes. + /// + /// This method can be expensive (it walks the element tree). + /// + /// See also: + /// + /// * [Scaffold.of], which provides access to the [ScaffoldState] object as a + /// whole, from which you can show bottom sheets, and so forth. + static bool hasDrawer( + BuildContext context, { + bool registerForUpdates = true, + }) { + if (registerForUpdates) { + final _ScaffoldScope? scaffold = context + .dependOnInheritedWidgetOfExactType<_ScaffoldScope>(); + return scaffold?.hasDrawer ?? false; + } else { + final ScaffoldState? scaffold = context + .findAncestorStateOfType(); + return scaffold?.hasDrawer ?? false; + } + } + + static Widget _defaultBottomSheetScrimBuilder( + BuildContext context, + Animation animation, + ) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + final double extentRemaining = + _kBottomSheetDominatesPercentage * (1.0 - animation.value); + final double floatingButtonVisibilityValue = + extentRemaining * _kBottomSheetDominatesPercentage * 10; + + final double opacity = math.max( + _kMinBottomSheetScrimOpacity, + _kMaxBottomSheetScrimOpacity - floatingButtonVisibilityValue, + ); + + return ModalBarrier( + dismissible: false, + color: Colors.black.withOpacity(opacity), + ); + }, + ); + } + + @override + ScaffoldState createState() => ScaffoldState(); +} + +/// State for a [Scaffold]. +/// +/// Can display [BottomSheet]s. Retrieve a [ScaffoldState] from the current +/// [BuildContext] using [Scaffold.of]. +class ScaffoldState extends State + with TickerProviderStateMixin, RestorationMixin + implements material.ScaffoldState { + @override + String? get restorationId => widget.restorationId; + + @protected + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_drawerOpened, 'drawer_open'); + registerForRestoration(_endDrawerOpened, 'end_drawer_open'); + } + + // DRAWER API + + final GlobalKey _drawerKey = + GlobalKey(); + final GlobalKey _endDrawerKey = + GlobalKey(); + + final GlobalKey _bodyKey = GlobalKey(); + + /// Whether this scaffold has a non-null [Scaffold.appBar]. + bool get hasAppBar => widget.appBar != null; + + /// Whether this scaffold has a non-null [Scaffold.drawer]. + bool get hasDrawer => widget.drawer != null; + + /// Whether this scaffold has a non-null [Scaffold.endDrawer]. + bool get hasEndDrawer => widget.endDrawer != null; + + /// Whether this scaffold has a non-null [Scaffold.floatingActionButton]. + bool get hasFloatingActionButton => widget.floatingActionButton != null; + + double? _appBarMaxHeight; + + /// The max height the [Scaffold.appBar] uses. + /// + /// This is based on the appBar preferred height plus the top padding. + double? get appBarMaxHeight => _appBarMaxHeight; + final RestorableBool _drawerOpened = RestorableBool(false); + final RestorableBool _endDrawerOpened = RestorableBool(false); + + /// Whether the [Scaffold.drawer] is opened. + /// + /// See also: + /// + /// * [ScaffoldState.openDrawer], which opens the [Scaffold.drawer] of a + /// [Scaffold]. + bool get isDrawerOpen => _drawerOpened.value; + + /// Whether the [Scaffold.endDrawer] is opened. + /// + /// See also: + /// + /// * [ScaffoldState.openEndDrawer], which opens the [Scaffold.endDrawer] of + /// a [Scaffold]. + bool get isEndDrawerOpen => _endDrawerOpened.value; + + void _drawerOpenedCallback(bool isOpened) { + if (_drawerOpened.value != isOpened && _drawerKey.currentState != null) { + setState(() { + _drawerOpened.value = isOpened; + }); + widget.onDrawerChanged?.call(isOpened); + } + } + + void _endDrawerOpenedCallback(bool isOpened) { + if (_endDrawerOpened.value != isOpened && + _endDrawerKey.currentState != null) { + setState(() { + _endDrawerOpened.value = isOpened; + }); + widget.onEndDrawerChanged?.call(isOpened); + } + } + + /// Opens the [Drawer] (if any). + /// + /// If the scaffold has a non-null [Scaffold.drawer], this function will cause + /// the drawer to begin its entrance animation. + /// + /// Normally this is not needed since the [Scaffold] automatically shows an + /// appropriate [IconButton], and handles the edge-swipe gesture, to show the + /// drawer. + /// + /// To close the drawer, use either [ScaffoldState.closeDrawer] or + /// [Navigator.pop]. + /// + /// See [Scaffold.of] for information about how to obtain the [ScaffoldState]. + void openDrawer() { + if (_endDrawerKey.currentState != null && _endDrawerOpened.value) { + _endDrawerKey.currentState!.close(); + } + _drawerKey.currentState?.open(); + } + + /// Opens the end side [Drawer] (if any). + /// + /// If the scaffold has a non-null [Scaffold.endDrawer], this function will cause + /// the end side drawer to begin its entrance animation. + /// + /// Normally this is not needed since the [Scaffold] automatically shows an + /// appropriate [IconButton], and handles the edge-swipe gesture, to show the + /// drawer. + /// + /// To close the drawer, use either [ScaffoldState.closeEndDrawer] or + /// [Navigator.pop]. + /// + /// See [Scaffold.of] for information about how to obtain the [ScaffoldState]. + void openEndDrawer() { + if (_drawerKey.currentState != null && _drawerOpened.value) { + _drawerKey.currentState!.close(); + } + _endDrawerKey.currentState?.open(); + } + + // Used for both the snackbar and material banner APIs + ScaffoldMessengerState? _scaffoldMessenger; + + // SNACKBAR API + ScaffoldFeatureController? _messengerSnackBar; + + // This is used to update the _messengerSnackBar by the ScaffoldMessenger. + void _updateSnackBar() { + final ScaffoldFeatureController? + messengerSnackBar = _scaffoldMessenger!._snackBars.isNotEmpty + ? _scaffoldMessenger!._snackBars.first + : null; + + if (_messengerSnackBar != messengerSnackBar) { + setState(() { + _messengerSnackBar = messengerSnackBar; + }); + } + } + + // MATERIAL BANNER API + + // The _messengerMaterialBanner represents the current MaterialBanner being managed by + // the ScaffoldMessenger, instead of the Scaffold. + ScaffoldFeatureController? + _messengerMaterialBanner; + + // This is used to update the _messengerMaterialBanner by the ScaffoldMessenger. + void _updateMaterialBanner() { + final ScaffoldFeatureController? + messengerMaterialBanner = _scaffoldMessenger!._materialBanners.isNotEmpty + ? _scaffoldMessenger!._materialBanners.first + : null; + + if (_messengerMaterialBanner != messengerMaterialBanner) { + setState(() { + _messengerMaterialBanner = messengerMaterialBanner; + }); + } + } + + // PERSISTENT BOTTOM SHEET API + + // Contains bottom sheets that may still be animating out of view. + // Important if the app/user takes an action that could repeatedly show a + // bottom sheet. + final List<_StandardBottomSheet> _dismissedBottomSheets = + <_StandardBottomSheet>[]; + PersistentBottomSheetController? _currentBottomSheet; + final GlobalKey _currentBottomSheetKey = GlobalKey(); + LocalHistoryEntry? _persistentSheetHistoryEntry; + + void _maybeBuildPersistentBottomSheet() { + if (widget.bottomSheet != null && _currentBottomSheet == null) { + // The new _currentBottomSheet is not a local history entry so a "back" button + // will not be added to the Scaffold's appbar and the bottom sheet will not + // support drag or swipe to dismiss. + final AnimationController animationController = + BottomSheet.createAnimationController(this)..value = 1.0; + bool persistentBottomSheetExtentChanged( + DraggableScrollableNotification notification, + ) { + if (notification.extent - notification.initialExtent > + precisionErrorTolerance) { + if (_persistentSheetHistoryEntry == null) { + _persistentSheetHistoryEntry = LocalHistoryEntry( + onRemove: () { + DraggableScrollableActuator.reset(notification.context); + showBodyScrim(false, 0.0); + _floatingActionButtonVisibilityController.value = 1.0; + _persistentSheetHistoryEntry = null; + }, + ); + ModalRoute.of( + context, + )!.addLocalHistoryEntry(_persistentSheetHistoryEntry!); + } + } else if (_persistentSheetHistoryEntry != null) { + _persistentSheetHistoryEntry!.remove(); + } + return false; + } + + // Stop the animation and unmount the dismissed sheets from the tree immediately, + // otherwise may cause duplicate GlobalKey assertion if the sheet sub-tree contains + // GlobalKey widgets. + if (_dismissedBottomSheets.isNotEmpty) { + final List<_StandardBottomSheet> sheets = List<_StandardBottomSheet>.of( + _dismissedBottomSheets, + growable: false, + ); + for (final _StandardBottomSheet sheet in sheets) { + sheet.animationController.reset(); + } + assert(_dismissedBottomSheets.isEmpty); + } + + _currentBottomSheet = _buildBottomSheet( + (BuildContext context) { + return NotificationListener( + onNotification: persistentBottomSheetExtentChanged, + child: DraggableScrollableActuator( + child: StatefulBuilder( + key: _currentBottomSheetKey, + builder: (BuildContext context, StateSetter setState) { + return widget.bottomSheet ?? const SizedBox.shrink(); + }, + ), + ), + ); + }, + isPersistent: true, + animationController: animationController, + ); + } + } + + void _closeCurrentBottomSheet() { + if (_currentBottomSheet != null) { + if (!_currentBottomSheet!._isLocalHistoryEntry) { + _currentBottomSheet!.close(); + } + assert(() { + _currentBottomSheet?._completer.future.whenComplete(() { + assert(_currentBottomSheet == null); + }); + return true; + }()); + } + } + + /// Closes [Scaffold.drawer] if it is currently opened. + /// + /// See [Scaffold.of] for information about how to obtain the [ScaffoldState]. + void closeDrawer() { + if (hasDrawer && isDrawerOpen) { + _drawerKey.currentState!.close(); + } + } + + /// Closes [Scaffold.endDrawer] if it is currently opened. + /// + /// See [Scaffold.of] for information about how to obtain the [ScaffoldState]. + void closeEndDrawer() { + if (hasEndDrawer && isEndDrawerOpen) { + _endDrawerKey.currentState!.close(); + } + } + + void _updatePersistentBottomSheet() { + _currentBottomSheetKey.currentState!.setState(() {}); + } + + PersistentBottomSheetController _buildBottomSheet( + WidgetBuilder builder, { + required bool isPersistent, + required AnimationController animationController, + Color? backgroundColor, + double? elevation, + ShapeBorder? shape, + Clip? clipBehavior, + BoxConstraints? constraints, + bool? enableDrag, + bool? showDragHandle, + bool shouldDisposeAnimationController = true, + }) { + assert(() { + if (widget.bottomSheet != null && + isPersistent && + _currentBottomSheet != null) { + throw FlutterError( + 'Scaffold.bottomSheet cannot be specified while a bottom sheet ' + 'displayed with showBottomSheet() is still visible.\n' + 'Rebuild the Scaffold with a null bottomSheet before calling showBottomSheet().', + ); + } + return true; + }()); + + final Completer completer = Completer(); + final GlobalKey<_StandardBottomSheetState> bottomSheetKey = + GlobalKey<_StandardBottomSheetState>(); + late _StandardBottomSheet bottomSheet; + + bool removedEntry = false; + bool doingDispose = false; + + void removePersistentSheetHistoryEntryIfNeeded() { + assert(isPersistent); + if (_persistentSheetHistoryEntry != null) { + _persistentSheetHistoryEntry!.remove(); + _persistentSheetHistoryEntry = null; + } + } + + void removeCurrentBottomSheet() { + removedEntry = true; + if (_currentBottomSheet == null) { + return; + } + assert(_currentBottomSheet!._widget == bottomSheet); + assert(bottomSheetKey.currentState != null); + _showFloatingActionButton(); + + if (isPersistent) { + removePersistentSheetHistoryEntryIfNeeded(); + } + + bottomSheetKey.currentState!.close(); + setState(() { + _showBodyScrim = false; + _bottomSheetScrimAnimationController.value = 0.0; + _currentBottomSheet = null; + }); + + if (!animationController.isDismissed) { + _dismissedBottomSheets.add(bottomSheet); + } + completer.complete(); + } + + final LocalHistoryEntry? entry = isPersistent + ? null + : LocalHistoryEntry( + onRemove: () { + if (!removedEntry && + _currentBottomSheet?._widget == bottomSheet && + !doingDispose) { + removeCurrentBottomSheet(); + } + }, + ); + + void removeEntryIfNeeded() { + if (!isPersistent && !removedEntry) { + assert(entry != null); + entry!.remove(); + removedEntry = true; + } + } + + bottomSheet = _StandardBottomSheet( + key: bottomSheetKey, + animationController: animationController, + enableDrag: enableDrag ?? !isPersistent, + showDragHandle: showDragHandle, + onClosing: () { + if (_currentBottomSheet == null) { + return; + } + assert(_currentBottomSheet!._widget == bottomSheet); + removeEntryIfNeeded(); + }, + onDismissed: () { + if (_dismissedBottomSheets.contains(bottomSheet)) { + setState(() { + _dismissedBottomSheets.remove(bottomSheet); + }); + } + }, + onDispose: () { + doingDispose = true; + removeEntryIfNeeded(); + if (shouldDisposeAnimationController) { + animationController.dispose(); + } + }, + builder: builder, + isPersistent: isPersistent, + backgroundColor: backgroundColor, + elevation: elevation, + shape: shape, + clipBehavior: clipBehavior, + constraints: constraints, + ); + + if (!isPersistent) { + ModalRoute.of(context)!.addLocalHistoryEntry(entry!); + } + + return PersistentBottomSheetController._( + bottomSheet, + completer, + entry != null ? entry.remove : removeCurrentBottomSheet, + (VoidCallback fn) { + bottomSheetKey.currentState?.setState(fn); + }, + !isPersistent, + ); + } + + /// Shows a Material Design bottom sheet in the nearest [Scaffold]. To show + /// a persistent bottom sheet, use the [Scaffold.bottomSheet]. + /// + /// Returns a controller that can be used to close and otherwise manipulate the + /// bottom sheet. + /// + /// To rebuild the bottom sheet (e.g. if it is stateful), call + /// [PersistentBottomSheetController.setState] on the controller returned by + /// this method. + /// + /// The new bottom sheet becomes a [LocalHistoryEntry] for the enclosing + /// [ModalRoute] and a back button is added to the app bar of the [Scaffold] + /// that closes the bottom sheet. + /// + /// The [transitionAnimationController] controls the bottom sheet's entrance and + /// exit animations. It's up to the owner of the controller to call + /// [AnimationController.dispose] when the controller is no longer needed. + /// + /// To create a persistent bottom sheet that is not a [LocalHistoryEntry] and + /// does not add a back button to the enclosing Scaffold's app bar, use the + /// [Scaffold.bottomSheet] constructor parameter. + /// + /// A persistent bottom sheet shows information that supplements the primary + /// content of the app. A persistent bottom sheet remains visible even when + /// the user interacts with other parts of the app. + /// + /// A closely related widget is a modal bottom sheet, which is an alternative + /// to a menu or a dialog and prevents the user from interacting with the rest + /// of the app. Modal bottom sheets can be created and displayed with the + /// [showModalBottomSheet] function. + /// + /// {@tool dartpad} + /// This example demonstrates how to use [showBottomSheet] to display a + /// bottom sheet when a user taps a button. It also demonstrates how to + /// close a bottom sheet using the Navigator. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_state.show_bottom_sheet.0.dart ** + /// {@end-tool} + /// + /// The [sheetAnimationStyle] parameter is used to override the bottom sheet + /// animation duration and reverse animation duration. + /// + /// If [AnimationStyle.duration] is provided, it will be used to override + /// the bottom sheet animation duration in the underlying + /// [BottomSheet.createAnimationController]. + /// + /// If [AnimationStyle.reverseDuration] is provided, it will be used to + /// override the bottom sheet reverse animation duration in the underlying + /// [BottomSheet.createAnimationController]. + /// + /// To disable the bottom sheet animation, use [AnimationStyle.noAnimation]. + /// + /// {@tool dartpad} + /// This sample showcases how to override the [showBottomSheet] animation + /// duration and reverse animation duration using [AnimationStyle]. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_state.show_bottom_sheet.1.dart ** + /// {@end-tool} + /// See also: + /// + /// * [BottomSheet], which becomes the parent of the widget returned by the + /// `builder`. + /// * [showBottomSheet], which calls this method given a [BuildContext]. + /// * [showModalBottomSheet], which can be used to display a modal bottom + /// sheet. + /// * [Scaffold.of], for information about how to obtain the [ScaffoldState]. + /// * The Material 2 spec at . + /// * The Material 3 spec at . + /// * [AnimationStyle], which is used to override the modal bottom sheet + /// animation duration and reverse animation duration. + PersistentBottomSheetController showBottomSheet( + WidgetBuilder builder, { + Color? backgroundColor, + double? elevation, + ShapeBorder? shape, + Clip? clipBehavior, + BoxConstraints? constraints, + bool? enableDrag, + bool? showDragHandle, + AnimationController? transitionAnimationController, + AnimationStyle? sheetAnimationStyle, + }) { + assert(() { + if (widget.bottomSheet != null) { + throw FlutterError( + 'Scaffold.bottomSheet cannot be specified while a bottom sheet ' + 'displayed with showBottomSheet() is still visible.\n' + 'Rebuild the Scaffold with a null bottomSheet before calling showBottomSheet().', + ); + } + return true; + }()); + assert(debugCheckHasMediaQuery(context)); + + _closeCurrentBottomSheet(); + final AnimationController controller = + (transitionAnimationController ?? + BottomSheet.createAnimationController( + this, + sheetAnimationStyle: sheetAnimationStyle, + )) + ..forward(); + setState(() { + _currentBottomSheet = _buildBottomSheet( + builder, + isPersistent: false, + animationController: controller, + backgroundColor: backgroundColor, + elevation: elevation, + shape: shape, + clipBehavior: clipBehavior, + constraints: constraints, + enableDrag: enableDrag, + showDragHandle: showDragHandle, + shouldDisposeAnimationController: transitionAnimationController == null, + ); + }); + return _currentBottomSheet!; + } + + // Floating Action Button API + late AnimationController _floatingActionButtonMoveController; + late FloatingActionButtonAnimator _floatingActionButtonAnimator; + FloatingActionButtonLocation? _previousFloatingActionButtonLocation; + FloatingActionButtonLocation? _floatingActionButtonLocation; + + late AnimationController _floatingActionButtonVisibilityController; + + /// Shows the [Scaffold.floatingActionButton]. + TickerFuture _showFloatingActionButton() { + return _floatingActionButtonVisibilityController.forward(); + } + + // Moves the Floating Action Button to the new Floating Action Button Location. + void _moveFloatingActionButton( + final FloatingActionButtonLocation newLocation, + ) { + FloatingActionButtonLocation? previousLocation = + _floatingActionButtonLocation; + double restartAnimationFrom = 0.0; + // If the Floating Action Button is moving right now, we need to start from a snapshot of the current transition. + if (_floatingActionButtonMoveController.isAnimating) { + previousLocation = _TransitionSnapshotFabLocation( + _previousFloatingActionButtonLocation!, + _floatingActionButtonLocation!, + _floatingActionButtonAnimator, + _floatingActionButtonMoveController.value, + ); + restartAnimationFrom = _floatingActionButtonAnimator.getAnimationRestart( + _floatingActionButtonMoveController.value, + ); + } + + setState(() { + _previousFloatingActionButtonLocation = previousLocation; + _floatingActionButtonLocation = newLocation; + }); + + // Animate the motion even when the fab is null so that if the exit animation is running, + // the old fab will start the motion transition while it exits instead of jumping to the + // new position. + _floatingActionButtonMoveController.forward(from: restartAnimationFrom); + } + + // iOS FEATURES - status bar tap, back gesture + + // On iOS, tapping the status bar scrolls the app's primary scrollable to the + // top. We implement this by looking up the primary scroll controller and + // scrolling it to the top when tapped. + void _handleStatusBarTap() { + final ScrollController? primaryScrollController = + PrimaryScrollController.maybeOf(context); + if (primaryScrollController != null && primaryScrollController.hasClients) { + primaryScrollController.animateTo( + 0.0, + duration: const Duration(milliseconds: 1000), + curve: Curves.easeOutCirc, + ); + } + } + + // INTERNALS + + late _ScaffoldGeometryNotifier _geometryNotifier; + + bool get _resizeToAvoidBottomInset { + return widget.resizeToAvoidBottomInset ?? true; + } + + @protected + @override + void initState() { + super.initState(); + _geometryNotifier = _ScaffoldGeometryNotifier( + const ScaffoldGeometry(), + context, + ); + _floatingActionButtonLocation = + widget.floatingActionButtonLocation ?? + _kDefaultFloatingActionButtonLocation; + _floatingActionButtonAnimator = + widget.floatingActionButtonAnimator ?? + _kDefaultFloatingActionButtonAnimator; + _previousFloatingActionButtonLocation = _floatingActionButtonLocation; + _floatingActionButtonMoveController = AnimationController( + vsync: this, + value: 1.0, + duration: kFloatingActionButtonSegue * 2, + ); + + _floatingActionButtonVisibilityController = AnimationController( + duration: kFloatingActionButtonSegue, + vsync: this, + ); + + _bottomSheetScrimAnimationController = AnimationController(vsync: this); + } + + @protected + @override + void didUpdateWidget(Scaffold oldWidget) { + super.didUpdateWidget(oldWidget); + // Update the Floating Action Button Animator, and then schedule the Floating Action Button for repositioning. + if (widget.floatingActionButtonAnimator != + oldWidget.floatingActionButtonAnimator) { + _floatingActionButtonAnimator = + widget.floatingActionButtonAnimator ?? + _kDefaultFloatingActionButtonAnimator; + } + if (widget.floatingActionButtonLocation != + oldWidget.floatingActionButtonLocation) { + _moveFloatingActionButton( + widget.floatingActionButtonLocation ?? + _kDefaultFloatingActionButtonLocation, + ); + } + if (widget.bottomSheet != oldWidget.bottomSheet) { + assert(() { + if (widget.bottomSheet != null && + (_currentBottomSheet?._isLocalHistoryEntry ?? false)) { + throw FlutterError.fromParts([ + ErrorSummary( + 'Scaffold.bottomSheet cannot be specified while a bottom sheet displayed ' + 'with showBottomSheet() is still visible.', + ), + ErrorHint( + 'Use the PersistentBottomSheetController ' + 'returned by showBottomSheet() to close the old bottom sheet before creating ' + 'a Scaffold with a (non null) bottomSheet.', + ), + ]); + } + return true; + }()); + if (widget.bottomSheet == null) { + _closeCurrentBottomSheet(); + } else if (widget.bottomSheet != null && oldWidget.bottomSheet == null) { + _maybeBuildPersistentBottomSheet(); + } else { + _updatePersistentBottomSheet(); + } + } + } + + @protected + @override + void didChangeDependencies() { + // Using maybeOf is valid here since both the Scaffold and ScaffoldMessenger + // are currently available for managing SnackBars. + final ScaffoldMessengerState? currentScaffoldMessenger = + ScaffoldMessenger.maybeOf(context); + // If our ScaffoldMessenger has changed, unregister with the old one first. + if (_scaffoldMessenger != null && + (currentScaffoldMessenger == null || + _scaffoldMessenger != currentScaffoldMessenger)) { + _scaffoldMessenger?._unregister(this); + } + // Register with the current ScaffoldMessenger, if there is one. + _scaffoldMessenger = currentScaffoldMessenger; + _scaffoldMessenger?._register(this); + + _maybeBuildPersistentBottomSheet(); + super.didChangeDependencies(); + } + + @protected + @override + void dispose() { + _geometryNotifier.dispose(); + _floatingActionButtonMoveController.dispose(); + _floatingActionButtonVisibilityController.dispose(); + _scaffoldMessenger?._unregister(this); + _drawerOpened.dispose(); + _endDrawerOpened.dispose(); + _bottomSheetScrimAnimationController.dispose(); + super.dispose(); + } + + void _addIfNonNull( + List children, + Widget? child, + Object childId, { + required bool removeLeftPadding, + required bool removeTopPadding, + required bool removeRightPadding, + required bool removeBottomPadding, + bool removeBottomInset = false, + bool maintainBottomViewPadding = false, + }) { + MediaQueryData data = MediaQuery.of(context).removePadding( + removeLeft: removeLeftPadding, + removeTop: removeTopPadding, + removeRight: removeRightPadding, + removeBottom: removeBottomPadding, + ); + if (removeBottomInset) { + data = data.removeViewInsets(removeBottom: true); + } + + if (maintainBottomViewPadding && data.viewInsets.bottom != 0.0) { + data = data.copyWith( + padding: data.padding.copyWith(bottom: data.viewPadding.bottom), + ); + } + + if (child != null) { + children.add( + LayoutId( + id: childId, + child: MediaQuery(data: data, child: child), + ), + ); + } + } + + void _buildEndDrawer(List children, TextDirection textDirection) { + if (widget.endDrawer != null) { + assert(hasEndDrawer); + _addIfNonNull( + children, + DrawerController( + key: _endDrawerKey, + alignment: DrawerAlignment.end, + drawerCallback: _endDrawerOpenedCallback, + dragStartBehavior: widget.drawerDragStartBehavior, + scrimColor: widget.drawerScrimColor, + edgeDragWidth: widget.drawerEdgeDragWidth, + enableOpenDragGesture: widget.endDrawerEnableOpenDragGesture, + isDrawerOpen: _endDrawerOpened.value, + drawerBarrierDismissible: widget.drawerBarrierDismissible, + child: widget.endDrawer!, + ), + _ScaffoldSlot.endDrawer, + // remove the side padding from the side we're not touching + removeLeftPadding: textDirection == TextDirection.ltr, + removeTopPadding: false, + removeRightPadding: textDirection == TextDirection.rtl, + removeBottomPadding: false, + ); + } + } + + void _buildDrawer(List children, TextDirection textDirection) { + if (widget.drawer != null) { + assert(hasDrawer); + _addIfNonNull( + children, + DrawerController( + key: _drawerKey, + alignment: DrawerAlignment.start, + drawerCallback: _drawerOpenedCallback, + dragStartBehavior: widget.drawerDragStartBehavior, + scrimColor: widget.drawerScrimColor, + edgeDragWidth: widget.drawerEdgeDragWidth, + enableOpenDragGesture: widget.drawerEnableOpenDragGesture, + isDrawerOpen: _drawerOpened.value, + drawerBarrierDismissible: widget.drawerBarrierDismissible, + child: widget.drawer!, + ), + _ScaffoldSlot.drawer, + // remove the side padding from the side we're not touching + removeLeftPadding: textDirection == TextDirection.rtl, + removeTopPadding: false, + removeRightPadding: textDirection == TextDirection.ltr, + removeBottomPadding: false, + ); + } + } + + late AnimationController _bottomSheetScrimAnimationController; + bool _showBodyScrim = false; + + /// Updates the state of the body scrim. + /// + /// This method is used to show or hide the body scrim and to set the animation value. + void showBodyScrim(bool value, double animationValue) { + if (_showBodyScrim != value) { + setState(() { + _showBodyScrim = value; + }); + } + if (_bottomSheetScrimAnimationController.value != animationValue) { + _bottomSheetScrimAnimationController.value = animationValue; + } + } + + @protected + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + assert(debugCheckHasDirectionality(context)); + final ThemeData themeData = Theme.of(context); + final TextDirection textDirection = Directionality.of(context); + + final List children = []; + _addIfNonNull( + children, + widget.body == null + ? null + : _BodyBuilder( + extendBody: widget.extendBody, + extendBodyBehindAppBar: widget.extendBodyBehindAppBar, + body: KeyedSubtree(key: _bodyKey, child: widget.body!), + ), + _ScaffoldSlot.body, + removeLeftPadding: false, + removeTopPadding: widget.appBar != null, + removeRightPadding: false, + removeBottomPadding: + widget.bottomNavigationBar != null || + widget.persistentFooterButtons != null, + removeBottomInset: _resizeToAvoidBottomInset, + ); + if (_showBodyScrim) { + _addIfNonNull( + children, + widget.bottomSheetScrimBuilder( + context, + _bottomSheetScrimAnimationController.view, + ), + _ScaffoldSlot.bodyScrim, + removeLeftPadding: true, + removeTopPadding: true, + removeRightPadding: true, + removeBottomPadding: true, + ); + } + + if (widget.appBar != null) { + final double topPadding = widget.primary + ? MediaQuery.paddingOf(context).top + : 0.0; + _appBarMaxHeight = + AppBar.preferredHeightFor(context, widget.appBar!.preferredSize) + + topPadding; + assert(_appBarMaxHeight! >= 0.0 && _appBarMaxHeight!.isFinite); + _addIfNonNull( + children, + ConstrainedBox( + constraints: BoxConstraints(maxHeight: _appBarMaxHeight!), + child: FlexibleSpaceBar.createSettings( + currentExtent: _appBarMaxHeight!, + child: widget.appBar!, + ), + ), + _ScaffoldSlot.appBar, + removeLeftPadding: false, + removeTopPadding: false, + removeRightPadding: false, + removeBottomPadding: true, + ); + } + + bool isSnackBarFloating = false; + double? snackBarWidth; + + if (_currentBottomSheet != null || _dismissedBottomSheets.isNotEmpty) { + final Widget stack = Stack( + alignment: Alignment.bottomCenter, + children: [ + ..._dismissedBottomSheets, + if (_currentBottomSheet != null) _currentBottomSheet!._widget, + ], + ); + _addIfNonNull( + children, + stack, + _ScaffoldSlot.bottomSheet, + removeLeftPadding: false, + removeTopPadding: true, + removeRightPadding: false, + removeBottomPadding: _resizeToAvoidBottomInset, + ); + } + + // SnackBar set by ScaffoldMessenger + if (_messengerSnackBar != null) { + final SnackBarBehavior snackBarBehavior = + _messengerSnackBar?._widget.behavior ?? + themeData.snackBarTheme.behavior ?? + SnackBarBehavior.fixed; + isSnackBarFloating = snackBarBehavior == SnackBarBehavior.floating; + snackBarWidth = + _messengerSnackBar?._widget.width ?? themeData.snackBarTheme.width; + + _addIfNonNull( + children, + _messengerSnackBar?._widget, + _ScaffoldSlot.snackBar, + removeLeftPadding: false, + removeTopPadding: true, + removeRightPadding: false, + removeBottomPadding: + widget.bottomNavigationBar != null || + widget.persistentFooterButtons != null, + maintainBottomViewPadding: !_resizeToAvoidBottomInset, + ); + } + + bool extendBodyBehindMaterialBanner = false; + // MaterialBanner set by ScaffoldMessenger + if (_messengerMaterialBanner != null) { + final MaterialBannerThemeData bannerTheme = MaterialBannerTheme.of( + context, + ); + final double elevation = + _messengerMaterialBanner?._widget.elevation ?? + bannerTheme.elevation ?? + 0.0; + extendBodyBehindMaterialBanner = elevation != 0.0; + + _addIfNonNull( + children, + _messengerMaterialBanner?._widget, + _ScaffoldSlot.materialBanner, + removeLeftPadding: false, + removeTopPadding: widget.appBar != null, + removeRightPadding: false, + removeBottomPadding: true, + maintainBottomViewPadding: !_resizeToAvoidBottomInset, + ); + } + + if (widget.persistentFooterButtons != null) { + _addIfNonNull( + children, + Container( + decoration: + widget.persistentFooterDecoration ?? + BoxDecoration( + border: Border( + top: Divider.createBorderSide(context, width: 1.0), + ), + ), + child: SafeArea( + top: false, + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(8), + child: Align( + alignment: widget.persistentFooterAlignment, + child: OverflowBar( + spacing: 8, + overflowAlignment: OverflowBarAlignment.end, + children: widget.persistentFooterButtons!, + ), + ), + ), + ), + ), + ), + _ScaffoldSlot.persistentFooter, + removeLeftPadding: false, + removeTopPadding: true, + removeRightPadding: false, + removeBottomPadding: widget.bottomNavigationBar != null, + maintainBottomViewPadding: !_resizeToAvoidBottomInset, + ); + } + + if (widget.bottomNavigationBar != null) { + _addIfNonNull( + children, + widget.bottomNavigationBar, + _ScaffoldSlot.bottomNavigationBar, + removeLeftPadding: false, + removeTopPadding: true, + removeRightPadding: false, + removeBottomPadding: false, + maintainBottomViewPadding: !_resizeToAvoidBottomInset, + ); + } + + _addIfNonNull( + children, + _FloatingActionButtonTransition( + fabMoveAnimation: _floatingActionButtonMoveController, + fabMotionAnimator: _floatingActionButtonAnimator, + geometryNotifier: _geometryNotifier, + currentController: _floatingActionButtonVisibilityController, + child: widget.floatingActionButton, + ), + _ScaffoldSlot.floatingActionButton, + removeLeftPadding: true, + removeTopPadding: true, + removeRightPadding: true, + removeBottomPadding: true, + ); + + switch (themeData.platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + _addIfNonNull( + children, + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _handleStatusBarTap, + // iOS accessibility automatically adds scroll-to-top to the clock in the status bar + excludeFromSemantics: true, + ), + _ScaffoldSlot.statusBar, + removeLeftPadding: false, + removeTopPadding: true, + removeRightPadding: false, + removeBottomPadding: true, + ); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + break; + } + + if (_endDrawerOpened.value) { + _buildDrawer(children, textDirection); + _buildEndDrawer(children, textDirection); + } else { + _buildEndDrawer(children, textDirection); + _buildDrawer(children, textDirection); + } + + // The minimum insets for contents of the Scaffold to keep visible. + final EdgeInsets minInsets = + MediaQuery.paddingOf( + context, + ).copyWith( + bottom: _resizeToAvoidBottomInset + ? MediaQuery.viewInsetsOf(context).bottom + : 0.0, + ); + + // The minimum viewPadding for interactive elements positioned by the + // Scaffold to keep within safe interactive areas. + final EdgeInsets minViewPadding = MediaQuery.viewPaddingOf(context) + .copyWith( + bottom: + _resizeToAvoidBottomInset && + MediaQuery.viewInsetsOf(context).bottom != 0.0 + ? 0.0 + : null, + ); + + return _ScaffoldScope( + hasDrawer: hasDrawer, + geometryNotifier: _geometryNotifier, + child: ScrollNotificationObserver( + child: Material( + color: widget.backgroundColor ?? themeData.scaffoldBackgroundColor, + child: AnimatedBuilder( + animation: _floatingActionButtonMoveController, + builder: (BuildContext context, Widget? child) { + return Actions( + actions: >{ + DismissIntent: _DismissDrawerAction(context), + }, + child: CustomMultiChildLayout( + delegate: _ScaffoldLayout( + extendBody: widget.extendBody, + extendBodyBehindAppBar: widget.extendBodyBehindAppBar, + minInsets: minInsets, + minViewPadding: minViewPadding, + currentFloatingActionButtonLocation: + _floatingActionButtonLocation!, + floatingActionButtonMoveAnimationProgress: + _floatingActionButtonMoveController.value, + floatingActionButtonMotionAnimator: + _floatingActionButtonAnimator, + geometryNotifier: _geometryNotifier, + previousFloatingActionButtonLocation: + _previousFloatingActionButtonLocation!, + textDirection: textDirection, + isSnackBarFloating: isSnackBarFloating, + extendBodyBehindMaterialBanner: + extendBodyBehindMaterialBanner, + snackBarWidth: snackBarWidth, + ), + children: children, + ), + ); + }, + ), + ), + ), + ); + } +} + +class _DismissDrawerAction extends DismissAction { + _DismissDrawerAction(this.context); + + final BuildContext context; + + @override + bool isEnabled(DismissIntent intent) { + return Scaffold.of(context).isDrawerOpen || + Scaffold.of(context).isEndDrawerOpen; + } + + @override + void invoke(DismissIntent intent) { + Scaffold.of(context).closeDrawer(); + Scaffold.of(context).closeEndDrawer(); + } +} + +/// An interface for controlling a feature of a [Scaffold]. +/// +/// Commonly obtained from [ScaffoldMessengerState.showSnackBar] or +/// [ScaffoldState.showBottomSheet]. +class ScaffoldFeatureController { + const ScaffoldFeatureController._( + this._widget, + this._completer, + this.close, + this.setState, + ); + final T _widget; + final Completer _completer; + + /// Completes when the feature controlled by this object is no longer visible. + Future get closed => _completer.future; + + /// Remove the feature (e.g., bottom sheet, snack bar, or material banner) from the scaffold. + final VoidCallback close; + + /// Mark the feature (e.g., bottom sheet or snack bar) as needing to rebuild. + final StateSetter? setState; +} + +class _StandardBottomSheet extends StatefulWidget { + const _StandardBottomSheet({ + super.key, + required this.animationController, + this.enableDrag = true, + this.showDragHandle, + required this.onClosing, + required this.onDismissed, + required this.builder, + this.isPersistent = false, + this.backgroundColor, + this.elevation, + this.shape, + this.clipBehavior, + this.constraints, + this.onDispose, + }); + + final AnimationController + animationController; // we control it, but it must be disposed by whoever created it. + final bool enableDrag; + final bool? showDragHandle; + final VoidCallback? onClosing; + final VoidCallback? onDismissed; + final VoidCallback? onDispose; + final WidgetBuilder builder; + final bool isPersistent; + final Color? backgroundColor; + final double? elevation; + final ShapeBorder? shape; + final Clip? clipBehavior; + final BoxConstraints? constraints; + + @override + _StandardBottomSheetState createState() => _StandardBottomSheetState(); +} + +class _StandardBottomSheetState extends State<_StandardBottomSheet> { + ParametricCurve animationCurve = _standardBottomSheetCurve; + + @override + void initState() { + super.initState(); + assert(widget.animationController.isForwardOrCompleted); + widget.animationController.addStatusListener(_handleStatusChange); + } + + @override + void dispose() { + widget.animationController.removeStatusListener(_handleStatusChange); + widget.onDispose?.call(); + super.dispose(); + } + + @override + void didUpdateWidget(_StandardBottomSheet oldWidget) { + super.didUpdateWidget(oldWidget); + assert(widget.animationController == oldWidget.animationController); + } + + void close() { + widget.animationController.reverse(); + widget.onClosing?.call(); + } + + void _handleDragStart(DragStartDetails details) { + // Allow the bottom sheet to track the user's finger accurately. + animationCurve = Curves.linear; + } + + void _handleDragEnd(DragEndDetails details, {bool? isClosing}) { + // Allow the bottom sheet to animate smoothly from its current position. + animationCurve = Split( + widget.animationController.value, + endCurve: _standardBottomSheetCurve, + ); + } + + void _handleStatusChange(AnimationStatus status) { + if (status.isDismissed) { + widget.onDismissed?.call(); + } + } + + bool extentChanged(DraggableScrollableNotification notification) { + final double extentRemaining = 1.0 - notification.extent; + final ScaffoldState scaffold = Scaffold.of(context); + if (extentRemaining < _kBottomSheetDominatesPercentage) { + scaffold._floatingActionButtonVisibilityController.value = + extentRemaining * _kBottomSheetDominatesPercentage * 10; + + final double scrimAnimationValue = + 1 - extentRemaining / _kBottomSheetDominatesPercentage; + scaffold.showBodyScrim(true, scrimAnimationValue); + } else { + scaffold._floatingActionButtonVisibilityController.value = 1.0; + scaffold.showBodyScrim(false, 0.0); + } + // If the Scaffold.bottomSheet != null, we're a persistent bottom sheet. + if (notification.extent == notification.minExtent && + scaffold.widget.bottomSheet == null && + notification.shouldCloseOnMinExtent) { + close(); + } + return false; + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: widget.animationController, + builder: (BuildContext context, Widget? child) { + return Align( + alignment: AlignmentDirectional.topStart, + heightFactor: animationCurve.transform( + widget.animationController.value, + ), + child: child, + ); + }, + child: Semantics( + container: true, + onDismiss: !widget.isPersistent ? close : null, + child: NotificationListener( + onNotification: extentChanged, + child: BottomSheet( + animationController: widget.animationController, + enableDrag: widget.enableDrag, + showDragHandle: widget.showDragHandle, + onDragStart: _handleDragStart, + onDragEnd: _handleDragEnd, + onClosing: widget.onClosing!, + builder: widget.builder, + backgroundColor: widget.backgroundColor, + elevation: widget.elevation, + shape: widget.shape, + clipBehavior: widget.clipBehavior, + constraints: widget.constraints, + ), + ), + ), + ); + } +} + +/// A [ScaffoldFeatureController] for standard bottom sheets. +/// +/// This is the type of objects returned by [ScaffoldState.showBottomSheet]. +/// +/// This controller is used to display both standard and persistent bottom +/// sheets. A bottom sheet is only persistent if it is set as the +/// [Scaffold.bottomSheet]. +class PersistentBottomSheetController + extends ScaffoldFeatureController<_StandardBottomSheet, void> + implements material.PersistentBottomSheetController { + const PersistentBottomSheetController._( + super.widget, + super.completer, + super.close, + StateSetter super.setState, + this._isLocalHistoryEntry, + ) : super._(); + + final bool _isLocalHistoryEntry; +} + +class _ScaffoldScope extends InheritedWidget { + const _ScaffoldScope({ + required this.hasDrawer, + required this.geometryNotifier, + required super.child, + }); + + final bool hasDrawer; + final _ScaffoldGeometryNotifier geometryNotifier; + + @override + bool updateShouldNotify(_ScaffoldScope oldWidget) { + return hasDrawer != oldWidget.hasDrawer; + } +} diff --git a/lib/http/video.dart b/lib/http/video.dart index fd046bc2e..db7876db0 100644 --- a/lib/http/video.dart +++ b/lib/http/video.dart @@ -278,7 +278,9 @@ class VideoHttp { } } - static Future videoRelation({required dynamic bvid}) async { + static Future> videoRelation({ + required String bvid, + }) async { var res = await Request().get( Api.videoRelation, queryParameters: { @@ -287,15 +289,9 @@ class VideoHttp { }, ); if (res.data['code'] == 0) { - return { - 'status': true, - 'data': VideoRelation.fromJson(res.data['data']), - }; + return Success(VideoRelation.fromJson(res.data['data'])); } else { - return { - 'status': false, - 'msg': res.data['message'], - }; + return Error(res.data['message']); } } diff --git a/lib/pages/article/view.dart b/lib/pages/article/view.dart index d6726de62..3f4d4674e 100644 --- a/lib/pages/article/view.dart +++ b/lib/pages/article/view.dart @@ -145,7 +145,6 @@ class _ArticlePageState extends CommonDynPageState { child: Padding( padding: EdgeInsets.only(right: padding), child: Scaffold( - key: scaffoldKey, backgroundColor: Colors.transparent, resizeToAvoidBottomInset: false, body: refreshIndicator( @@ -179,7 +178,6 @@ class _ArticlePageState extends CommonDynPageState { // if (kDebugMode) debugPrint('json page'); content = OpusContent( opus: controller.opus!, - callback: imageCallback, maxWidth: maxWidth, ); } else if (controller.opusData?.modules.moduleBlocked != null) { @@ -201,7 +199,6 @@ class _ArticlePageState extends CommonDynPageState { context: context, html: controller.articleData!.content!, maxWidth: maxWidth, - callback: imageCallback, ), ); } else { @@ -212,7 +209,6 @@ class _ArticlePageState extends CommonDynPageState { context: context, element: res.body!.children[index], maxWidth: maxWidth, - callback: imageCallback, ); }, separatorBuilder: (context, index) => @@ -444,7 +440,6 @@ class _ArticlePageState extends CommonDynPageState { onDelete: (item, subIndex) => controller.onRemove(index, item, subIndex), upMid: controller.upMid, - callback: imageCallback, onCheckReply: (item) => controller.onCheckReply(item, isManual: true), onToggleTop: (item) => controller.onToggleTop( diff --git a/lib/pages/article/widgets/html_render.dart b/lib/pages/article/widgets/html_render.dart index 6810c70cd..6b655156b 100644 --- a/lib/pages/article/widgets/html_render.dart +++ b/lib/pages/article/widgets/html_render.dart @@ -14,7 +14,6 @@ Widget htmlRender({ int? imgCount, List? imgList, required double maxWidth, - Function(List, int)? callback, }) { // if (kDebugMode) debugPrint('htmlRender'); final extensions = [ @@ -51,14 +50,10 @@ Widget htmlRender({ tag: imgUrl, child: GestureDetector( onTap: () { - if (callback != null) { - callback([imgUrl], 0); - } else { - PageUtils.imageView( - imgList: [SourceModel(url: imgUrl)], - quality: 60, - ); - } + PageUtils.imageView( + imgList: [SourceModel(url: imgUrl)], + quality: 60, + ); }, child: CachedNetworkImage( width: size, diff --git a/lib/pages/article/widgets/opus_content.dart b/lib/pages/article/widgets/opus_content.dart index a54bdca25..3c0aeff2c 100644 --- a/lib/pages/article/widgets/opus_content.dart +++ b/lib/pages/article/widgets/opus_content.dart @@ -28,13 +28,11 @@ import 'package:re_highlight/styles/github.dart'; class OpusContent extends StatelessWidget { final List opus; - final void Function(List, int)? callback; final double maxWidth; const OpusContent({ super.key, required this.opus, - this.callback, required this.maxWidth, }); @@ -183,14 +181,10 @@ class OpusContent extends StatelessWidget { tag: pic.url!, child: GestureDetector( onTap: () { - if (callback != null) { - callback!([pic.url!], 0); - } else { - PageUtils.imageView( - imgList: [SourceModel(url: pic.url!)], - quality: 60, - ); - } + PageUtils.imageView( + imgList: [SourceModel(url: pic.url!)], + quality: 60, + ); }, child: Center( child: CachedNetworkImage( diff --git a/lib/pages/common/dyn/common_dyn_page.dart b/lib/pages/common/dyn/common_dyn_page.dart index 795aab3bb..d03f8ba46 100644 --- a/lib/pages/common/dyn/common_dyn_page.dart +++ b/lib/pages/common/dyn/common_dyn_page.dart @@ -13,7 +13,6 @@ import 'package:PiliPlus/pages/video/reply_reply/view.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/feed_back.dart'; import 'package:PiliPlus/utils/num_utils.dart'; -import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/storage_key.dart'; import 'package:easy_debounce/easy_throttle.dart'; @@ -22,15 +21,12 @@ import 'package:flutter/rendering.dart'; import 'package:get/get.dart' hide ContextExtensionss; abstract class CommonDynPageState extends State - with TickerProviderStateMixin { + with SingleTickerProviderStateMixin { CommonDynController get controller; late final scrollController = ScrollController()..addListener(listener); - late final scaffoldKey = GlobalKey(); - bool get horizontalPreview => !isPortrait && controller.horizontalPreview; - Function(List imgList, int index)? imageCallback; dynamic get arguments; @@ -87,17 +83,6 @@ abstract class CommonDynPageState extends State final size = MediaQuery.sizeOf(context); maxWidth = size.width; isPortrait = size.isPortrait; - imageCallback = horizontalPreview - ? (imgList, index) { - hideFab(); - PageUtils.onHorizontalPreview( - scaffoldKey, - this, - imgList, - index, - ); - } - : null; padding = MediaQuery.viewPaddingOf(context); } @@ -193,7 +178,7 @@ abstract class CommonDynPageState extends State onDelete: (item, subIndex) => controller.onRemove(index, item, subIndex), upMid: controller.upMid, - callback: imageCallback, + onViewImage: hideFab, onCheckReply: (item) => controller.onCheckReply(item, isManual: true), onToggleTop: (item) => controller.onToggleTop( @@ -270,7 +255,7 @@ abstract class CommonDynPageState extends State arguments: arguments, ); } else { - ScaffoldState? scaffoldState = Scaffold.maybeOf(context); + final scaffoldState = Scaffold.maybeOf(context); if (scaffoldState != null) { hideFab(); scaffoldState.showBottomSheet( diff --git a/lib/pages/common/publish/common_rich_text_pub_page.dart b/lib/pages/common/publish/common_rich_text_pub_page.dart index e54481e50..cbaf4444e 100644 --- a/lib/pages/common/publish/common_rich_text_pub_page.dart +++ b/lib/pages/common/publish/common_rich_text_pub_page.dart @@ -62,8 +62,10 @@ abstract class CommonRichTextPubPageState @override void dispose() { - for (var i in pathList) { - File(i).tryDel(); + if (Utils.isMobile) { + for (var i in pathList) { + File(i).tryDel(); + } } super.dispose(); } diff --git a/lib/pages/common/reply_controller.dart b/lib/pages/common/reply_controller.dart index b480363fb..140227cb8 100644 --- a/lib/pages/common/reply_controller.dart +++ b/lib/pages/common/reply_controller.dart @@ -7,7 +7,6 @@ import 'package:PiliPlus/http/reply.dart'; import 'package:PiliPlus/models/common/reply/reply_sort_type.dart'; import 'package:PiliPlus/pages/common/common_list_controller.dart'; import 'package:PiliPlus/pages/video/reply_new/view.dart'; -import 'package:PiliPlus/services/account_service.dart'; import 'package:PiliPlus/utils/feed_back.dart'; import 'package:PiliPlus/utils/reply_utils.dart'; import 'package:PiliPlus/utils/request_utils.dart'; @@ -21,14 +20,12 @@ import 'package:get/get.dart'; import 'package:get/get_navigation/src/dialog/dialog_route.dart'; abstract class ReplyController extends CommonListController { - RxInt count = (-1).obs; + final RxInt count = (-1).obs; - late Rx sortType; - late Rx mode; + late final Rx sortType; + late final Rx mode; - late final savedReplies = ?>{}; - - AccountService accountService = Get.find(); + final savedReplies = ?>{}; Int64? upMid; Int64? cursorNext; diff --git a/lib/pages/common/slide/common_collapse_slide_page.dart b/lib/pages/common/slide/common_collapse_slide_page.dart deleted file mode 100644 index 46c027b9a..000000000 --- a/lib/pages/common/slide/common_collapse_slide_page.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'dart:io' show Platform; - -import 'package:PiliPlus/pages/common/slide/common_slide_page.dart'; -import 'package:flutter/material.dart'; - -abstract class CommonCollapseSlidePage extends CommonSlidePage { - const CommonCollapseSlidePage({super.key, super.enableSlide}); -} - -abstract class CommonCollapseSlidePageState - extends CommonSlidePageState { - late bool isInit = true; - - @override - void initState() { - super.initState(); - init(); - } - - void init() { - if (Platform.isAndroid) { - WidgetsBinding.instance.addPostFrameCallback((_) { - isInit = false; - }); - } - } - - @override - Widget build(BuildContext context) { - if (Platform.isAndroid) { - return Stack( - clipBehavior: Clip.none, - children: [ - if (isInit) - const CustomScrollView( - physics: NeverScrollableScrollPhysics(), - ), - super.build(context), - ], - ); - } - return super.build(context); - } -} diff --git a/lib/pages/common/slide/common_slide_page.dart b/lib/pages/common/slide/common_slide_page.dart index f2801bdb4..17cdd7cde 100644 --- a/lib/pages/common/slide/common_slide_page.dart +++ b/lib/pages/common/slide/common_slide_page.dart @@ -11,8 +11,7 @@ abstract class CommonSlidePage extends StatefulWidget { final bool enableSlide; } -abstract class CommonSlidePageState extends State - with TickerProviderStateMixin { +mixin CommonSlideMixin on State, TickerProvider { Offset? downPos; bool? isSliding; late double maxWidth; diff --git a/lib/pages/dynamics/widgets/content_panel.dart b/lib/pages/dynamics/widgets/content_panel.dart index 86a9a1463..76e1114fa 100644 --- a/lib/pages/dynamics/widgets/content_panel.dart +++ b/lib/pages/dynamics/widgets/content_panel.dart @@ -15,7 +15,6 @@ Widget content( required bool isSave, required bool isDetail, required double maxWidth, - Function(List, int)? callback, }) { if (floor == 1) { maxWidth -= 24; @@ -99,7 +98,7 @@ Widget content( ), ) .toList(), - callback: callback, + fullScreen: true, ), ], ), diff --git a/lib/pages/dynamics/widgets/dyn_content.dart b/lib/pages/dynamics/widgets/dyn_content.dart index 40fb7fd65..cafa375be 100644 --- a/lib/pages/dynamics/widgets/dyn_content.dart +++ b/lib/pages/dynamics/widgets/dyn_content.dart @@ -13,7 +13,6 @@ List dynContent( required bool isSave, required bool isDetail, required double maxWidth, - Function(List, int)? callback, }) { final moduleDynamic = item.modules.moduleDynamic; return [ @@ -25,7 +24,6 @@ List dynContent( isDetail: isDetail, item: item, floor: floor, - callback: callback, maxWidth: maxWidth, ), module( @@ -35,7 +33,6 @@ List dynContent( isDetail: isDetail, item: item, floor: floor, - callback: callback, maxWidth: maxWidth, ), if (moduleDynamic?.additional case final additional?) diff --git a/lib/pages/dynamics/widgets/dynamic_panel.dart b/lib/pages/dynamics/widgets/dynamic_panel.dart index 9afe5fd77..bf5bf21ed 100644 --- a/lib/pages/dynamics/widgets/dynamic_panel.dart +++ b/lib/pages/dynamics/widgets/dynamic_panel.dart @@ -12,7 +12,6 @@ class DynamicPanel extends StatelessWidget { final double maxWidth; final bool isDetail; final ValueChanged? onRemove; - final Function(List, int)? callback; final bool isSave; final Function(bool isTop, dynamic dynId)? onSetTop; final VoidCallback? onBlock; @@ -25,7 +24,6 @@ class DynamicPanel extends StatelessWidget { required this.maxWidth, this.isDetail = false, this.onRemove, - this.callback, this.isSave = false, this.onSetTop, this.onBlock, @@ -80,7 +78,6 @@ class DynamicPanel extends StatelessWidget { isDetail: isDetail, item: item, floor: 1, - callback: callback, maxWidth: maxWidth, ), const SizedBox(height: 2), diff --git a/lib/pages/dynamics/widgets/forward_panel.dart b/lib/pages/dynamics/widgets/forward_panel.dart index 09cbb2e92..a6b87e7f8 100644 --- a/lib/pages/dynamics/widgets/forward_panel.dart +++ b/lib/pages/dynamics/widgets/forward_panel.dart @@ -16,7 +16,6 @@ Widget forwardPanel( required bool isSave, required bool isDetail, required double maxWidth, - Function(List, int)? callback, }) { final moduleDynamic = orig.modules.moduleDynamic; final major = moduleDynamic?.major; @@ -44,7 +43,6 @@ Widget forwardPanel( isDetail: isDetail, item: orig, floor: floor + 1, - callback: callback, maxWidth: maxWidth - 30, ), ], diff --git a/lib/pages/dynamics/widgets/live_panel_sub.dart b/lib/pages/dynamics/widgets/live_panel_sub.dart index e85453b7a..bb6e2ad0d 100644 --- a/lib/pages/dynamics/widgets/live_panel_sub.dart +++ b/lib/pages/dynamics/widgets/live_panel_sub.dart @@ -12,7 +12,6 @@ Widget livePanelSub( required DynamicItemModel item, required bool isDetail, required double maxWidth, - Function(List, int)? callback, }) { LivePlayInfo? live = item .modules diff --git a/lib/pages/dynamics/widgets/module_panel.dart b/lib/pages/dynamics/widgets/module_panel.dart index c92dd8591..c22726995 100644 --- a/lib/pages/dynamics/widgets/module_panel.dart +++ b/lib/pages/dynamics/widgets/module_panel.dart @@ -37,7 +37,6 @@ Widget module( required bool isSave, required bool isDetail, required double maxWidth, - Function(List, int)? callback, }) { final moduleDynamic = item.modules.moduleDynamic; final major = moduleDynamic?.major; @@ -74,7 +73,6 @@ Widget module( floor: floor, isSave: isSave, isDetail: isDetail, - callback: callback, maxWidth: maxWidth, ); // 转发 @@ -85,7 +83,6 @@ Widget module( isSave: isSave, orig: item.orig!, isDetail: isDetail, - callback: callback, floor: floor + 1, maxWidth: maxWidth, ); @@ -314,7 +311,6 @@ Widget module( isDetail: isDetail, item: item, floor: floor, - callback: callback, maxWidth: maxWidth, ); diff --git a/lib/pages/dynamics/widgets/video_panel.dart b/lib/pages/dynamics/widgets/video_panel.dart index f7d437599..c28f61a77 100644 --- a/lib/pages/dynamics/widgets/video_panel.dart +++ b/lib/pages/dynamics/widgets/video_panel.dart @@ -15,7 +15,6 @@ Widget videoSeasonWidget( required bool isSave, required bool isDetail, required double maxWidth, - Function(List, int)? callback, }) { // type archive ugcSeason // archive 视频/显示发布人 diff --git a/lib/pages/dynamics_detail/view.dart b/lib/pages/dynamics_detail/view.dart index dbe026d8a..8d815dfd7 100644 --- a/lib/pages/dynamics_detail/view.dart +++ b/lib/pages/dynamics_detail/view.dart @@ -108,7 +108,6 @@ class _DynamicDetailPageState extends CommonDynPageState { child: DynamicPanel( item: controller.dynItem, isDetail: true, - callback: imageCallback, maxWidth: maxWidth - this.padding.horizontal - 2 * padding, isDetailPortraitW: isPortrait, ), @@ -139,7 +138,6 @@ class _DynamicDetailPageState extends CommonDynPageState { child: DynamicPanel( item: controller.dynItem, isDetail: true, - callback: imageCallback, maxWidth: (maxWidth - this.padding.horizontal) * (flex / (flex + flex1)) - @@ -156,7 +154,6 @@ class _DynamicDetailPageState extends CommonDynPageState { child: Padding( padding: EdgeInsets.only(right: padding), child: Scaffold( - key: scaffoldKey, backgroundColor: Colors.transparent, resizeToAvoidBottomInset: false, body: refreshIndicator( diff --git a/lib/pages/episode_panel/view.dart b/lib/pages/episode_panel/view.dart index 8bf61e2b0..46a14db34 100644 --- a/lib/pages/episode_panel/view.dart +++ b/lib/pages/episode_panel/view.dart @@ -19,24 +19,23 @@ import 'package:PiliPlus/models/user/info.dart'; import 'package:PiliPlus/models_new/pgc/pgc_info_model/episode.dart' as pgc; import 'package:PiliPlus/models_new/video/video_detail/episode.dart' as ugc; import 'package:PiliPlus/models_new/video/video_detail/page.dart'; -import 'package:PiliPlus/models_new/video/video_relation/data.dart'; -import 'package:PiliPlus/pages/common/slide/common_collapse_slide_page.dart'; +import 'package:PiliPlus/pages/common/slide/common_slide_page.dart'; import 'package:PiliPlus/pages/video/controller.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/widgets/page.dart'; import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/date_utils.dart'; import 'package:PiliPlus/utils/duration_utils.dart'; +import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/id_utils.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; -import 'package:flutter/foundation.dart' show kDebugMode; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide TabBarView; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; -import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; -class EpisodePanel extends CommonCollapseSlidePage { +class EpisodePanel extends CommonSlidePage { const EpisodePanel({ super.key, super.enableSlide, @@ -83,7 +82,8 @@ class EpisodePanel extends CommonCollapseSlidePage { State createState() => _EpisodePanelState(); } -class _EpisodePanelState extends CommonCollapseSlidePageState { +class _EpisodePanelState extends State + with TickerProviderStateMixin, CommonSlideMixin { // tab late final TabController _tabController = TabController( initialIndex: widget.initialTabIndex, @@ -92,7 +92,10 @@ class _EpisodePanelState extends CommonCollapseSlidePageState { )..addListener(listener); late final RxInt _currentTabIndex = _tabController.index.obs; - List get _getCurrEpisodes => widget.type == EpisodeType.season + late final showTitle = widget.showTitle; + + List get _getCurrEpisodes => + widget.type == EpisodeType.season ? widget.list[_currentTabIndex.value].episodes : widget.list[_currentTabIndex.value]; @@ -104,10 +107,10 @@ class _EpisodePanelState extends CommonCollapseSlidePageState { ); late final List _isReversed; - late final List _itemScrollController; + late final List _itemScrollController; // fav - Rx? _favState; + Rx>? _favState; void listener() { _currentTabIndex.value = _tabController.index; @@ -116,7 +119,7 @@ class _EpisodePanelState extends CommonCollapseSlidePageState { @override void didUpdateWidget(EpisodePanel oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.showTitle) { + if (showTitle) { return; } @@ -126,9 +129,11 @@ class _EpisodePanelState extends CommonCollapseSlidePageState { _currentItemIndex = newItemIndex; try { _itemScrollController[_currentTabIndex.value].jumpTo( - index: newItemIndex, + _calcItemOffset(newItemIndex), ); - } catch (_) {} + } catch (_) { + if (kDebugMode) rethrow; + } } } @@ -147,37 +152,28 @@ class _EpisodePanelState extends CommonCollapseSlidePageState { @override void initState() { super.initState(); + _currentItemIndex = _findCurrentItemIndex; _itemScrollController = List.generate( widget.list.length, - (_) => ItemScrollController(), + (i) => ScrollController( + initialScrollOffset: i == widget.initialTabIndex + ? _calcItemOffset(_currentItemIndex) + : 0, + ), + growable: false, ); _isReversed = List.filled(widget.list.length, false); if (widget.type == EpisodeType.season && Accounts.main.isLogin) { - _favState = LoadingState.loading().obs; + _favState = LoadingState.loading().obs; VideoHttp.videoRelation(bvid: widget.bvid).then( (result) { - if (result['status']) { - VideoRelation data = result['data']; - _favState!.value = Success(data.seasonFav ?? false); + if (result case Success(:var response)) { + _favState!.value = Success(response.seasonFav ?? false); } }, ); } - - _currentItemIndex = _findCurrentItemIndex; - } - - @override - void init() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - isInit = false; - _itemScrollController[widget.initialTabIndex].jumpTo( - index: _currentItemIndex, - ); - } - }); } @override @@ -185,6 +181,10 @@ class _EpisodePanelState extends CommonCollapseSlidePageState { _tabController ..removeListener(listener) ..dispose(); + _favState?.close(); + for (var e in _itemScrollController) { + e.dispose(); + } super.dispose(); } @@ -225,8 +225,8 @@ class _EpisodePanelState extends CommonCollapseSlidePageState { } return Material( - color: widget.showTitle ? theme.colorScheme.surface : null, - type: widget.showTitle ? MaterialType.canvas : MaterialType.transparency, + color: showTitle ? theme.colorScheme.surface : null, + type: showTitle ? MaterialType.canvas : MaterialType.transparency, child: Column( children: [ _buildToolbar(theme), @@ -257,55 +257,109 @@ class _EpisodePanelState extends CommonCollapseSlidePageState { return _buildBody(theme, 0, _getCurrEpisodes); } - Widget _buildBody(ThemeData theme, int tabIndex, List episodes) { + double _calcItemOffset(int index) { + if (showTitle) { + final episodes = _getCurrEpisodes; + double offset = 0; + for (var i = 0; i < index; i++) { + offset += _calcItemHeight(episodes[i]); + } + return offset + 7; + } else { + return index * 100 + 7; + } + } + + double _calcItemHeight(ugc.BaseEpisodeItem episode) { + if (episode is ugc.EpisodeItem && episode.pages!.length > 1) { + return 145; // 98 + 2 + 10 + 35 + } + return 100; + } + + Widget _buildBody( + ThemeData theme, + int tabIndex, + List episodes, + ) { final isCurrTab = tabIndex == widget.initialTabIndex; return KeepAliveWrapper( - builder: (context) => ScrollablePositionedList.separated( - padding: EdgeInsets.only( - top: 7, - bottom: MediaQuery.viewPaddingOf(context).bottom + 100, - ), + builder: (context) => CustomScrollView( reverse: _isReversed[tabIndex], - itemCount: episodes.length, physics: const AlwaysScrollableScrollPhysics(), - itemBuilder: (BuildContext context, int itemIndex) { - final ugc.BaseEpisodeItem episode = episodes[itemIndex]; - final isCurrItem = isCurrTab ? itemIndex == _currentItemIndex : false; - Widget episodeItem = _buildEpisodeItem( - theme: theme, - episode: episode, - index: itemIndex, - length: episodes.length, - isCurrentIndex: isCurrItem, - ); - if (episode is ugc.EpisodeItem && - widget.showTitle && - episode.pages!.length > 1) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - episodeItem, - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 5, + controller: _itemScrollController[tabIndex], + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + top: 7, + bottom: MediaQuery.viewPaddingOf(context).bottom + 100, + ), + sliver: showTitle + ? SliverVariedExtentList.builder( + itemCount: episodes.length, + itemBuilder: (context, index) { + final episode = episodes[index]; + final isCurrItem = isCurrTab + ? index == _currentItemIndex + : false; + Widget episodeItem = _buildEpisodeItem( + theme: theme, + episode: episode, + index: index, + length: episodes.length, + isCurrentIndex: isCurrItem, + ); + if (episode is ugc.EpisodeItem && + episode.pages!.length > 1) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + episodeItem, // 98 + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 5, + ), // 10 + child: PagesPanel( + // 35 + list: isCurrTab && isCurrItem + ? null + : episode.pages, + cover: episode.arc?.pic, + heroTag: widget.heroTag, + ugcIntroController: widget.ugcIntroController!, + bvid: + episode.bvid ?? IdUtils.av2bv(episode.aid!), + ), + ), + ], + ); + } + return episodeItem; + }, + itemExtentBuilder: (index, _) => + _calcItemHeight(episodes[index]), + ) + : SliverFixedExtentList.builder( + itemCount: episodes.length, + itemBuilder: (context, index) { + final episode = episodes[index]; + final isCurrItem = isCurrTab + ? index == _currentItemIndex + : false; + return _buildEpisodeItem( + theme: theme, + episode: episode, + index: index, + length: episodes.length, + isCurrentIndex: isCurrItem, + ); + }, + itemExtent: 100, ), - child: PagesPanel( - list: isCurrTab && isCurrItem ? null : episode.pages, - cover: episode.arc?.pic, - heroTag: widget.heroTag, - ugcIntroController: widget.ugcIntroController!, - bvid: episode.bvid ?? IdUtils.av2bv(episode.aid!), - ), - ), - ], - ); - } - return episodeItem; - }, - itemScrollController: _itemScrollController[tabIndex], - separatorBuilder: (context, index) => const SizedBox(height: 2), + ), + ], ), ); } @@ -360,142 +414,149 @@ class _EpisodePanelState extends CommonCollapseSlidePageState { } late final Color primary = theme.colorScheme.primary; - return SizedBox( - height: 98, - child: Material( - type: MaterialType.transparency, - child: InkWell( - onTap: () { - if (episode.badge == "会员") { - UserInfoData? userInfo = Pref.userInfoCache; - int vipStatus = userInfo?.vipStatus ?? 0; - if (vipStatus != 1) { - SmartDialog.showToast('需要大会员'); - // return; + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SizedBox( + height: 98, + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () { + if (episode.badge == "会员") { + UserInfoData? userInfo = Pref.userInfoCache; + int vipStatus = userInfo?.vipStatus ?? 0; + if (vipStatus != 1) { + SmartDialog.showToast('需要大会员'); + // return; + } } - } - SmartDialog.showToast('切换到:$title'); - widget.onClose?.call(); - if (!widget.showTitle) { - _currentItemIndex = index; - } - widget.onChangeEpisode(episode); - if (widget.type == EpisodeType.season) { - try { - Get.find( - tag: widget.ugcIntroController!.heroTag, - ).seasonCid = episode.cid; - } catch (_) {} - } - }, - onLongPress: () { - if (cover?.isNotEmpty == true) { - imageSaveDialog(title: title, cover: cover, bvid: bvid); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: StyleString.safeSpace, - vertical: 5, - ), - child: Row( - spacing: 10, - children: [ - if (cover?.isNotEmpty == true) - Stack( - clipBehavior: Clip.none, - children: [ - NetworkImgLayer( - src: cover, - width: 140.8, - height: 88, - ), - if (duration != null && duration > 0) - PBadge( - text: DurationUtils.formatDuration(duration), - right: 6.0, - bottom: 6.0, - type: PBadgeType.gray, + SmartDialog.showToast('切换到:$title'); + widget.onClose?.call(); + if (!showTitle) { + _currentItemIndex = index; + } + widget.onChangeEpisode(episode); + if (widget.type == EpisodeType.season) { + try { + Get.find( + tag: widget.ugcIntroController!.heroTag, + ).seasonCid = episode.cid; + } catch (_) { + if (kDebugMode) rethrow; + } + } + }, + onLongPress: () { + if (cover?.isNotEmpty == true) { + imageSaveDialog(title: title, cover: cover, bvid: bvid); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: StyleString.safeSpace, + vertical: 5, + ), + child: Row( + spacing: 10, + children: [ + if (cover?.isNotEmpty == true) + Stack( + clipBehavior: Clip.none, + children: [ + NetworkImgLayer( + src: cover, + width: 140.8, + height: 88, ), - if (isCharging == true) - const PBadge( - text: '充电专属', - top: 6, - right: 6, - type: PBadgeType.error, - ) - else if (episode.badge != null) - PBadge( - text: episode.badge, - top: 6, - right: 6, - type: switch (episode.badge) { - '预告' => PBadgeType.gray, - '限免' => PBadgeType.free, - _ => PBadgeType.primary, - }, - ), - ], - ) - else if (isCurrentIndex) - Image.asset( - 'assets/images/live.png', - color: primary, - height: 12, - semanticLabel: "正在播放:", - ), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - title, - textAlign: TextAlign.start, - style: TextStyle( - fontSize: theme.textTheme.bodyMedium!.fontSize, - height: 1.42, - letterSpacing: 0.3, - fontWeight: isCurrentIndex ? FontWeight.bold : null, - color: isCurrentIndex ? primary : null, + if (duration != null && duration > 0) + PBadge( + text: DurationUtils.formatDuration(duration), + right: 6.0, + bottom: 6.0, + type: PBadgeType.gray, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - if (pubdate != null) - Text( - DateFormatUtils.format(pubdate), - maxLines: 1, - style: TextStyle( - fontSize: 12, - height: 1, - color: theme.colorScheme.outline, - overflow: TextOverflow.clip, + if (isCharging == true) + const PBadge( + text: '充电专属', + top: 6, + right: 6, + type: PBadgeType.error, + ) + else if (episode.badge != null) + PBadge( + text: episode.badge, + top: 6, + right: 6, + type: switch (episode.badge) { + '预告' => PBadgeType.gray, + '限免' => PBadgeType.free, + _ => PBadgeType.primary, + }, ), - ), - if (view != null) ...[ - const SizedBox(height: 2), - Row( - spacing: 8, - children: [ - StatWidget( - value: view, - type: StatType.play, - ), - if (danmaku != null) - StatWidget( - value: danmaku, - type: StatType.danmaku, - ), - ], - ), ], - ], + ) + else if (isCurrentIndex) + Image.asset( + 'assets/images/live.png', + color: primary, + height: 12, + semanticLabel: "正在播放:", + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + title, + textAlign: TextAlign.start, + style: TextStyle( + fontSize: theme.textTheme.bodyMedium!.fontSize, + height: 1.42, + letterSpacing: 0.3, + fontWeight: isCurrentIndex + ? FontWeight.bold + : null, + color: isCurrentIndex ? primary : null, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (pubdate != null) + Text( + DateFormatUtils.format(pubdate), + maxLines: 1, + style: TextStyle( + fontSize: 12, + height: 1, + color: theme.colorScheme.outline, + overflow: TextOverflow.clip, + ), + ), + if (view != null) ...[ + const SizedBox(height: 2), + Row( + spacing: 8, + children: [ + StatWidget( + value: view, + type: StatType.play, + ), + if (danmaku != null) + StatWidget( + value: danmaku, + type: StatType.danmaku, + ), + ], + ), + ], + ], + ), ), - ), - ], + ], + ), ), ), ), @@ -503,7 +564,7 @@ class _EpisodePanelState extends CommonCollapseSlidePageState { ); } - Widget _buildFavBtn(LoadingState loadingState) { + Widget _buildFavBtn(LoadingState loadingState) { return switch (loadingState) { Success(:var response) => mediumButton( tooltip: response ? '取消订阅' : '订阅', @@ -535,9 +596,19 @@ class _EpisodePanelState extends CommonCollapseSlidePageState { onPressed: () => widget.onReverse?.call(), ); + void _animToTopOrBottom({bool top = true}) { + final tabIndex = _currentTabIndex.value; + _itemScrollController[tabIndex].animTo( + top ^ _isReversed[tabIndex] + ? 0 + : _calcItemOffset(_getCurrEpisodes.length), + duration: const Duration(milliseconds: 200), + ); + } + Widget _buildToolbar(ThemeData theme) => Container( height: 45, - padding: EdgeInsets.symmetric(horizontal: widget.showTitle ? 14 : 6), + padding: EdgeInsets.symmetric(horizontal: showTitle ? 14 : 6), decoration: BoxDecoration( border: Border( bottom: BorderSide( @@ -547,7 +618,7 @@ class _EpisodePanelState extends CommonCollapseSlidePageState { ), child: Row( children: [ - if (widget.showTitle) + if (showTitle) Text( widget.type.title, style: theme.textTheme.titleMedium, @@ -556,52 +627,26 @@ class _EpisodePanelState extends CommonCollapseSlidePageState { mediumButton( tooltip: '跳至顶部', icon: Icons.vertical_align_top, - onPressed: () { - try { - final currentTabIndex = _currentTabIndex.value; - _itemScrollController[currentTabIndex].scrollTo( - index: !_isReversed[currentTabIndex] - ? 0 - : _getCurrEpisodes.length - 1, - duration: const Duration(milliseconds: 200), - ); - } catch (e) { - if (kDebugMode) debugPrint('to top: $e'); - } - }, + onPressed: _animToTopOrBottom, ), mediumButton( tooltip: '跳至底部', icon: Icons.vertical_align_bottom, - onPressed: () { - try { - final currentTabIndex = _currentTabIndex.value; - _itemScrollController[currentTabIndex].scrollTo( - index: !_isReversed[currentTabIndex] - ? _getCurrEpisodes.length - 1 - : 0, - duration: const Duration(milliseconds: 200), - ); - } catch (e) { - if (kDebugMode) debugPrint('to bottom: $e'); - } - }, + onPressed: () => _animToTopOrBottom(top: false), ), mediumButton( tooltip: '跳至当前', icon: Icons.my_location, onPressed: () async { - try { - final currentTabIndex = _currentTabIndex.value; - if (currentTabIndex != widget.initialTabIndex) { - _tabController.animateTo(widget.initialTabIndex); - await Future.delayed(const Duration(milliseconds: 225)); - } - _itemScrollController[widget.initialTabIndex].scrollTo( - index: _currentItemIndex, - duration: const Duration(milliseconds: 200), - ); - } catch (_) {} + final currentTabIndex = _currentTabIndex.value; + if (currentTabIndex != widget.initialTabIndex) { + _tabController.animateTo(widget.initialTabIndex); + await Future.delayed(const Duration(milliseconds: 225)); + } + _itemScrollController[widget.initialTabIndex].animTo( + _calcItemOffset(_currentItemIndex), + duration: const Duration(milliseconds: 200), + ); }, ), if (widget.isSupportReverse == true) diff --git a/lib/pages/music/view.dart b/lib/pages/music/view.dart index 614e02364..8c102f36e 100644 --- a/lib/pages/music/view.dart +++ b/lib/pages/music/view.dart @@ -164,7 +164,6 @@ class _MusicDetailPageState extends CommonDynPageState { child: Padding( padding: EdgeInsets.only(right: padding), child: Scaffold( - key: scaffoldKey, backgroundColor: Colors.transparent, resizeToAvoidBottomInset: false, body: refreshIndicator( diff --git a/lib/pages/pgc_review/child/view.dart b/lib/pages/pgc_review/child/view.dart index 4fac3b43e..5c028e224 100644 --- a/lib/pages/pgc_review/child/view.dart +++ b/lib/pages/pgc_review/child/view.dart @@ -82,15 +82,10 @@ class _PgcReviewChildPageState extends State color: theme.colorScheme.outline.withValues(alpha: 0.1), ); return switch (loadingState) { - Loading() => SliverToBoxAdapter( - child: IgnorePointer( - child: ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) => const VideoReplySkeleton(), - itemCount: 8, - ), - ), + Loading() => SliverPrototypeExtentList.builder( + prototypeItem: const VideoReplySkeleton(), + itemBuilder: (_, _) => const VideoReplySkeleton(), + itemCount: 8, ), Success(:var response) => response?.isNotEmpty == true diff --git a/lib/pages/setting/models/extra_settings.dart b/lib/pages/setting/models/extra_settings.dart index 0be40ec3c..dccccc491 100644 --- a/lib/pages/setting/models/extra_settings.dart +++ b/lib/pages/setting/models/extra_settings.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'dart:math' show pi, max; import 'package:PiliPlus/common/widgets/image/custom_grid_view.dart' - show ImageModel; + show CustomGridView, ImageModel; import 'package:PiliPlus/common/widgets/pendant_avatar.dart'; import 'package:PiliPlus/common/widgets/refresh_indicator.dart'; import 'package:PiliPlus/grpc/reply.dart'; @@ -328,12 +328,13 @@ List get extraSettings => [ setKey: SettingBoxKey.continuePlayingPart, defaultVal: true, ), - const SettingsModel( + SettingsModel( settingsType: SettingsType.sw1tch, title: '横屏在侧栏打开图片预览', - leading: Icon(Icons.photo_outlined), + leading: const Icon(Icons.photo_outlined), setKey: SettingBoxKey.horizontalPreview, defaultVal: false, + onChanged: (value) => CustomGridView.horizontalPreview = value, ), getBanwordModel( context: Get.context!, @@ -714,7 +715,7 @@ List get extraSettings => [ setKey: SettingBoxKey.slideDismissReplyPage, defaultVal: Platform.isIOS, onChanged: (value) { - CommonSlidePageState.slideDismissReplyPage = value; + CommonSlideMixin.slideDismissReplyPage = value; }, ), const SettingsModel( diff --git a/lib/pages/setting/models/style_settings.dart b/lib/pages/setting/models/style_settings.dart index 5a2bcaa64..22dd8f4e6 100644 --- a/lib/pages/setting/models/style_settings.dart +++ b/lib/pages/setting/models/style_settings.dart @@ -566,7 +566,7 @@ List get styleSettings => [ setKey: SettingBoxKey.isPureBlackTheme, defaultVal: false, onChanged: (value) { - if (Get.theme.brightness == Brightness.dark || Pref.darkVideoPage) { + if (Get.isDarkMode || Pref.darkVideoPage) { Get.forceAppUpdate(); } }, diff --git a/lib/pages/video/ai_conclusion/view.dart b/lib/pages/video/ai_conclusion/view.dart index 999681957..cead90a61 100644 --- a/lib/pages/video/ai_conclusion/view.dart +++ b/lib/pages/video/ai_conclusion/view.dart @@ -1,12 +1,12 @@ import 'package:PiliPlus/models_new/video/video_ai_conclusion/model_result.dart'; -import 'package:PiliPlus/pages/common/slide/common_collapse_slide_page.dart'; +import 'package:PiliPlus/pages/common/slide/common_slide_page.dart'; import 'package:PiliPlus/pages/video/controller.dart'; import 'package:PiliPlus/utils/duration_utils.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -class AiConclusionPanel extends CommonCollapseSlidePage { +class AiConclusionPanel extends CommonSlidePage { final AiConclusionResult item; const AiConclusionPanel({ @@ -18,15 +18,8 @@ class AiConclusionPanel extends CommonCollapseSlidePage { State createState() => _AiDetailState(); } -class _AiDetailState extends CommonCollapseSlidePageState { - final _controller = ScrollController(); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - +class _AiDetailState extends State + with SingleTickerProviderStateMixin, CommonSlideMixin { @override Widget buildPage(ThemeData theme) { return Material( @@ -58,10 +51,18 @@ class _AiDetailState extends CommonCollapseSlidePageState { ); } + late Key _key; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _key = ValueKey(PrimaryScrollController.of(context).hashCode); + } + @override Widget buildList(ThemeData theme) { return CustomScrollView( - controller: _controller, + key: _key, physics: const AlwaysScrollableScrollPhysics(), slivers: [ if (widget.item.summary?.isNotEmpty == true) ...[ diff --git a/lib/pages/video/controller.dart b/lib/pages/video/controller.dart index 0f4bf4330..c343da3b0 100644 --- a/lib/pages/video/controller.dart +++ b/lib/pages/video/controller.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:PiliPlus/common/widgets/pair.dart'; import 'package:PiliPlus/common/widgets/progress_bar/segment_progress_bar.dart'; +import 'package:PiliPlus/common/widgets/scaffold/scaffold.dart'; import 'package:PiliPlus/http/constants.dart'; import 'package:PiliPlus/http/fav.dart'; import 'package:PiliPlus/http/init.dart'; @@ -53,7 +54,7 @@ import 'package:dio/dio.dart' show Options; import 'package:easy_debounce/easy_throttle.dart'; import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; import 'package:flutter/foundation.dart' show kDebugMode; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide Scaffold, ScaffoldState; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:flutter_volume_controller/flutter_volume_controller.dart'; import 'package:get/get.dart' hide ContextExtensionss; @@ -102,7 +103,6 @@ class VideoDetailController extends GetxController // 是否开始自动播放 存在多p的情况下,第二p需要为true final RxBool autoPlay = true.obs; - final scaffoldKey = GlobalKey(); final childKey = GlobalKey(); PlPlayerController plPlayerController = PlPlayerController.getInstance() @@ -154,9 +154,10 @@ class VideoDetailController extends GetxController late double videoHeight; void animToTop() { - if (scrollKey.currentState?.outerController.hasClients == true) { - scrollKey.currentState!.outerController.animateTo( - scrollKey.currentState!.outerController.offset, + final outerController = scrollKey.currentState!.outerController; + if (outerController.hasClients) { + outerController.animateTo( + outerController.offset, duration: const Duration(milliseconds: 500), curve: Curves.easeInOut, ); @@ -361,7 +362,7 @@ class VideoDetailController extends GetxController } catch (_) {} }, panelTitle: watchLaterTitle, - getBvId: () => bvid, + bvid: bvid, count: args['count'], loadMoreMedia: getMediaList, desc: _mediaDesc, @@ -1508,8 +1509,8 @@ class VideoDetailController extends GetxController idx = subtitles.indexWhere((i) => !i.lan!.startsWith('ai')) + 1; if (idx == 0) { if (preference == SubtitlePrefType.on || - (preference == SubtitlePrefType.auto && - Utils.isMobile && + (Utils.isMobile && + preference == SubtitlePrefType.auto && (await FlutterVolumeController.getVolume() ?? 0) <= 0)) { idx = 1; } diff --git a/lib/pages/video/introduction/pgc/view.dart b/lib/pages/video/introduction/pgc/view.dart index 64d87fe70..5d7cba4e0 100644 --- a/lib/pages/video/introduction/pgc/view.dart +++ b/lib/pages/video/introduction/pgc/view.dart @@ -15,6 +15,7 @@ import 'package:PiliPlus/pages/video/introduction/pgc/controller.dart'; import 'package:PiliPlus/pages/video/introduction/pgc/widgets/pgc_panel.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/widgets/action_item.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/widgets/triple_state.dart'; +import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/num_utils.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:flutter/material.dart'; @@ -43,25 +44,23 @@ class PgcIntroPage extends StatefulWidget { State createState() => _PgcIntroPageState(); } -class _PgcIntroPageState extends TripleState - with AutomaticKeepAliveClientMixin { +class _PgcIntroPageState extends TripleState { @override - late PgcIntroController introController; - late VideoDetailController videoDetailCtr; - - @override - bool get wantKeepAlive => true; + late final PgcIntroController introController; + late final VideoDetailController videoDetailCtr; @override void initState() { super.initState(); - introController = Get.put(PgcIntroController(), tag: widget.heroTag); + introController = Get.putOrFind( + PgcIntroController.new, + tag: widget.heroTag, + ); videoDetailCtr = Get.find(tag: widget.heroTag); } @override Widget build(BuildContext context) { - super.build(context); final ThemeData theme = Theme.of(context); final item = introController.pgcItem; final isLandscape = widget.isLandscape; diff --git a/lib/pages/video/introduction/pgc/widgets/intro_detail.dart b/lib/pages/video/introduction/pgc/widgets/intro_detail.dart index 568d06e09..2c53c560b 100644 --- a/lib/pages/video/introduction/pgc/widgets/intro_detail.dart +++ b/lib/pages/video/introduction/pgc/widgets/intro_detail.dart @@ -6,7 +6,7 @@ import 'package:PiliPlus/common/widgets/stat/stat.dart'; import 'package:PiliPlus/models/common/stat_type.dart'; import 'package:PiliPlus/models_new/pgc/pgc_info_model/result.dart'; import 'package:PiliPlus/models_new/video/video_tag/data.dart'; -import 'package:PiliPlus/pages/common/slide/common_collapse_slide_page.dart'; +import 'package:PiliPlus/pages/common/slide/common_slide_page.dart'; import 'package:PiliPlus/pages/pgc_review/view.dart'; import 'package:PiliPlus/pages/search/widgets/search_text.dart'; import 'package:PiliPlus/utils/extension.dart'; @@ -14,7 +14,7 @@ import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/material.dart' hide TabBarView; import 'package:get/get.dart'; -class PgcIntroPanel extends CommonCollapseSlidePage { +class PgcIntroPanel extends CommonSlidePage { final PgcInfoModel item; final List? videoTags; @@ -29,7 +29,8 @@ class PgcIntroPanel extends CommonCollapseSlidePage { State createState() => _IntroDetailState(); } -class _IntroDetailState extends CommonCollapseSlidePageState { +class _IntroDetailState extends State + with TickerProviderStateMixin, CommonSlideMixin { late final _tabController = TabController(length: 2, vsync: this); final _controller = ScrollController(); diff --git a/lib/pages/video/introduction/pgc/widgets/pgc_panel.dart b/lib/pages/video/introduction/pgc/widgets/pgc_panel.dart index 6214e1402..e43cfbcbc 100644 --- a/lib/pages/video/introduction/pgc/widgets/pgc_panel.dart +++ b/lib/pages/video/introduction/pgc/widgets/pgc_panel.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:PiliPlus/models/user/info.dart'; import 'package:PiliPlus/models_new/pgc/pgc_info_model/episode.dart'; import 'package:PiliPlus/models_new/pgc/pgc_info_model/new_ep.dart'; import 'package:PiliPlus/models_new/video/video_detail/episode.dart' @@ -37,22 +36,23 @@ class PgcPanel extends StatefulWidget { class _PgcPanelState extends State { late int currentIndex; - final ScrollController listViewScrollCtr = ScrollController(); + late final ScrollController listViewScrollCtr; // 默认未开通 - late int vipStatus; + late final bool vipStatus; late int cid; late final VideoDetailController videoDetailCtr; - StreamSubscription? _listener; + late final StreamSubscription _listener; @override void initState() { super.initState(); cid = widget.cid!; currentIndex = widget.pages.indexWhere((e) => e.cid == cid); - scrollToIndex(); + listViewScrollCtr = ScrollController( + initialScrollOffset: currentIndex * 150.0, + ); - UserInfoData? userInfo = Pref.userInfoCache; - vipStatus = userInfo?.vipStatus ?? 0; + vipStatus = Pref.userInfoCache?.vipStatus != 1; videoDetailCtr = Get.find(tag: widget.heroTag); @@ -67,7 +67,7 @@ class _PgcPanelState extends State { @override void dispose() { - _listener?.cancel(); + _listener.cancel(); listViewScrollCtr.dispose(); super.dispose(); } @@ -134,6 +134,7 @@ class _PgcPanelState extends State { SizedBox( height: 60, child: ListView.builder( + key: const PageStorageKey(_PgcPanelState), padding: EdgeInsets.zero, controller: listViewScrollCtr, scrollDirection: Axis.horizontal, @@ -163,7 +164,7 @@ class _PgcPanelState extends State { child: InkWell( borderRadius: const BorderRadius.all(Radius.circular(6)), onTap: () { - if (item.badge == '会员' && vipStatus != 1) { + if (item.badge == '会员' && vipStatus) { SmartDialog.showToast('需要大会员'); } widget.onChangeEpisode(item); diff --git a/lib/pages/video/introduction/ugc/controller.dart b/lib/pages/video/introduction/ugc/controller.dart index 5c303a103..46d2be3a3 100644 --- a/lib/pages/video/introduction/ugc/controller.dart +++ b/lib/pages/video/introduction/ugc/controller.dart @@ -22,7 +22,6 @@ import 'package:PiliPlus/models_new/video/video_detail/section.dart'; import 'package:PiliPlus/models_new/video/video_detail/staff.dart'; import 'package:PiliPlus/models_new/video/video_detail/stat_detail.dart'; import 'package:PiliPlus/models_new/video/video_detail/ugc_season.dart'; -import 'package:PiliPlus/models_new/video/video_relation/data.dart'; import 'package:PiliPlus/pages/common/common_intro_controller.dart'; import 'package:PiliPlus/pages/dynamics_repost/view.dart'; import 'package:PiliPlus/pages/video/controller.dart'; @@ -166,19 +165,18 @@ class UgcIntroController extends CommonIntroController with ReloadMixin { Future queryAllStatus() async { var result = await VideoHttp.videoRelation(bvid: bvid); - if (result['status']) { - VideoRelation data = result['data']; + if (result case Success(:var response)) { late final stat = videoDetail.value.stat!; - if (data.like!) { + if (response.like!) { stat.like = max(1, stat.like); } - if (data.favorite!) { + if (response.favorite!) { stat.favorite = max(1, stat.favorite); } - hasLike.value = data.like!; - hasDislike.value = data.dislike!; - coinNum.value = data.coin!; - hasFav.value = data.favorite!; + hasLike.value = response.like!; + hasDislike.value = response.dislike!; + coinNum.value = response.coin!; + hasFav.value = response.favorite!; } } diff --git a/lib/pages/video/introduction/ugc/view.dart b/lib/pages/video/introduction/ugc/view.dart index 812133db3..75a94d0e6 100644 --- a/lib/pages/video/introduction/ugc/view.dart +++ b/lib/pages/video/introduction/ugc/view.dart @@ -54,22 +54,23 @@ class UgcIntroPanel extends StatefulWidget { State createState() => _UgcIntroPanelState(); } -class _UgcIntroPanelState extends TripleState - with AutomaticKeepAliveClientMixin { +class _UgcIntroPanelState extends TripleState { @override - late UgcIntroController introController; + late final UgcIntroController introController; late final VideoDetailController videoDetailCtr = Get.find(tag: widget.heroTag); @override void initState() { super.initState(); - introController = Get.put(UgcIntroController(), tag: widget.heroTag); + introController = Get.putOrFind( + UgcIntroController.new, + tag: widget.heroTag, + ); } @override Widget build(BuildContext context) { - super.build(context); final ThemeData theme = Theme.of(context); const expandTheme = ExpandableThemeData( animationDuration: Duration(milliseconds: 300), @@ -963,7 +964,4 @@ class _UgcIntroPanelState extends TripleState ), ); } - - @override - bool get wantKeepAlive => true; } diff --git a/lib/pages/video/introduction/ugc/widgets/page.dart b/lib/pages/video/introduction/ugc/widgets/page.dart index eed96aac3..986a4e719 100644 --- a/lib/pages/video/introduction/ugc/widgets/page.dart +++ b/lib/pages/video/introduction/ugc/widgets/page.dart @@ -35,8 +35,8 @@ class PagesPanel extends StatefulWidget { class _PagesPanelState extends State { late int cid; int pageIndex = -1; - late VideoDetailController _videoDetailController; - final ScrollController _scrollController = ScrollController(); + late final VideoDetailController _videoDetailController; + late final ScrollController _scrollController; StreamSubscription? _listener; List get pages => @@ -48,28 +48,32 @@ class _PagesPanelState extends State { _videoDetailController = Get.find( tag: widget.heroTag, ); + double offset = 0; if (widget.list == null) { cid = widget.ugcIntroController.cid.value; pageIndex = pages.indexWhere((Part e) => e.cid == cid); - _listener = _videoDetailController.cid.listen((int cid) { + offset = targetOffset; + _listener = _videoDetailController.cid.listen((cid) { this.cid = cid; - pageIndex = max(0, pages.indexWhere((Part e) => e.cid == cid)); + pageIndex = max(0, pages.indexWhere((e) => e.cid == cid)); if (!mounted) return; setState(() {}); jumpToCurr(); }); - WidgetsBinding.instance.addPostFrameCallback((_) { - jumpToCurr(); - }); } + _scrollController = ScrollController(initialScrollOffset: offset); + } + + double get targetOffset { + const double itemWidth = 150; + return max(0, pageIndex * itemWidth - itemWidth / 2); } void jumpToCurr() { if (!_scrollController.hasClients || pages.isEmpty) { return; } - const double itemWidth = 150; - final double targetOffset = (pageIndex * itemWidth - itemWidth / 2).clamp( + final double targetOffset = this.targetOffset.clamp( _scrollController.position.minScrollExtent, _scrollController.position.maxScrollExtent, ); @@ -136,6 +140,7 @@ class _PagesPanelState extends State { SizedBox( height: 35, child: ListView.builder( + key: PageStorageKey(widget.bvid), controller: _scrollController, scrollDirection: Axis.horizontal, itemCount: pages.length, diff --git a/lib/pages/video/medialist/view.dart b/lib/pages/video/medialist/view.dart index b31867a3b..dd12d07fb 100644 --- a/lib/pages/video/medialist/view.dart +++ b/lib/pages/video/medialist/view.dart @@ -9,21 +9,20 @@ import 'package:PiliPlus/models/common/badge_type.dart'; import 'package:PiliPlus/models/common/stat_type.dart'; import 'package:PiliPlus/models_new/media_list/media_list.dart'; import 'package:PiliPlus/models_new/video/video_detail/episode.dart'; -import 'package:PiliPlus/pages/common/slide/common_collapse_slide_page.dart'; +import 'package:PiliPlus/pages/common/slide/common_slide_page.dart'; import 'package:PiliPlus/utils/duration_utils.dart'; import 'package:flutter/material.dart' hide RefreshCallback; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; -import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; -class MediaListPanel extends CommonCollapseSlidePage { +class MediaListPanel extends CommonSlidePage { const MediaListPanel({ super.key, required this.mediaList, required this.onChangeEpisode, this.panelTitle, - required this.getBvId, + required this.bvid, required this.loadMoreMedia, required this.count, required this.desc, @@ -32,37 +31,33 @@ class MediaListPanel extends CommonCollapseSlidePage { this.onDelete, }); - final List mediaList; + final RxList mediaList; final ValueChanged onChangeEpisode; final String? panelTitle; - final Function getBvId; + final String bvid; final VoidCallback loadMoreMedia; final int? count; final bool desc; final VoidCallback onReverse; final RefreshCallback? loadPrevious; - final Function(MediaListItemModel item, int index)? onDelete; + final void Function(MediaListItemModel item, int index)? onDelete; @override State createState() => _MediaListPanelState(); } -class _MediaListPanelState - extends CommonCollapseSlidePageState { - final _controller = ItemScrollController(); +class _MediaListPanelState extends State + with SingleTickerProviderStateMixin, CommonSlideMixin { + late final ScrollController _controller; @override - void init() { - final bvid = widget.getBvId(); + void initState() { + super.initState(); + final bvid = widget.bvid; final bvIndex = widget.mediaList.indexWhere((item) => item.bvid == bvid); - if (bvIndex != -1) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - isInit = false; - _controller.jumpTo(index: bvIndex); - } - }); - } + _controller = ScrollController( + initialScrollOffset: bvIndex == -1 ? 0 : bvIndex * 100.0 + 7, + ); } @override @@ -120,31 +115,37 @@ class _MediaListPanelState : _buildList(theme); } - Widget _buildList(ThemeData theme) => Obx( - () { - final showDelBtn = widget.onDelete != null && widget.mediaList.length > 1; - return ScrollablePositionedList.separated( - itemScrollController: _controller, - physics: const AlwaysScrollableScrollPhysics(), - itemCount: widget.mediaList.length, - padding: EdgeInsets.only( - top: 7, - bottom: MediaQuery.viewPaddingOf(context).bottom + 100, + Widget _buildList(ThemeData theme) { + final showDelBtn = widget.onDelete != null && widget.mediaList.length > 1; + return CustomScrollView( + controller: _controller, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + top: 7, + bottom: MediaQuery.viewPaddingOf(context).bottom + 100, + ), + sliver: Obx( + () => SliverFixedExtentList.builder( + itemExtent: 100, + itemCount: widget.mediaList.length, + itemBuilder: (context, index) { + if (index == widget.mediaList.length - 1 && + (widget.count == null || + widget.mediaList.length < widget.count!)) { + widget.loadMoreMedia(); + } + var item = widget.mediaList[index]; + final isCurr = item.bvid == widget.bvid; + return _buildItem(theme, index, item, isCurr, showDelBtn); + }, + ), + ), ), - itemBuilder: ((context, index) { - if (index == widget.mediaList.length - 1 && - (widget.count == null || - widget.mediaList.length < widget.count!)) { - widget.loadMoreMedia(); - } - var item = widget.mediaList[index]; - final isCurr = item.bvid == widget.getBvId(); - return _buildItem(theme, index, item, isCurr, showDelBtn); - }), - separatorBuilder: (context, index) => const SizedBox(height: 2), - ); - }, - ); + ], + ); + } Widget _buildItem( ThemeData theme, @@ -153,146 +154,151 @@ class _MediaListPanelState bool isCurr, bool showDelBtn, ) { - return SizedBox( - height: 98, - child: Material( - type: MaterialType.transparency, - child: InkWell( - onTap: () { - if (item.type != 2) { - SmartDialog.showToast('不支持播放该类型视频'); - return; - } - Get.back(); - widget.onChangeEpisode(item); - }, - onLongPress: () => imageSaveDialog( - title: item.title, - cover: item.cover, - aid: item.aid, - bvid: item.bvid, - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 5, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - clipBehavior: Clip.none, - children: [ - NetworkImgLayer( - src: item.cover, - width: 140.8, - height: 88, - ), - if (item.badge?.isNotEmpty == true) - PBadge( - text: item.badge, - right: 6.0, - top: 6.0, - type: switch (item.badge) { - '充电专属' => PBadgeType.error, - _ => PBadgeType.primary, - }, - ), - PBadge( - text: DurationUtils.formatDuration( - item.duration, - ), - right: 6.0, - bottom: 6.0, - type: PBadgeType.gray, - ), - ], - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SizedBox( + height: 98, + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () { + if (item.type != 2) { + SmartDialog.showToast('不支持播放该类型视频'); + return; + } + Get.back(); + widget.onChangeEpisode(item); + }, + onLongPress: () => imageSaveDialog( + title: item.title, + cover: item.cover, + aid: item.aid, + bvid: item.bvid, + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 5, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + clipBehavior: Clip.none, children: [ - Text( - item.title!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: isCurr ? FontWeight.bold : null, - color: isCurr ? theme.colorScheme.primary : null, - ), + NetworkImgLayer( + src: item.cover, + width: 140.8, + height: 88, ), - if (item.type == 24 && - item.intro?.isNotEmpty == true) ...[ - const SizedBox(height: 3), + if (item.badge?.isNotEmpty == true) + PBadge( + text: item.badge, + right: 6.0, + top: 6.0, + type: switch (item.badge) { + '充电专属' => PBadgeType.error, + _ => PBadgeType.primary, + }, + ), + PBadge( + text: DurationUtils.formatDuration( + item.duration, + ), + right: 6.0, + bottom: 6.0, + type: PBadgeType.gray, + ), + ], + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - item.intro!, + item.title!, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 13, + fontWeight: isCurr ? FontWeight.bold : null, + color: isCurr + ? theme.colorScheme.primary + : null, + ), + ), + if (item.type == 24 && + item.intro?.isNotEmpty == true) ...[ + const SizedBox(height: 3), + Text( + item.intro!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.outline, + ), + ), + ], + const Spacer(), + Text( + item.upper!.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, color: theme.colorScheme.outline, ), ), + if (item.type == 2) ...[ + const SizedBox(height: 3), + Row( + spacing: 8, + children: [ + StatWidget( + type: StatType.play, + value: item.cntInfo!.play, + ), + StatWidget( + type: StatType.danmaku, + value: item.cntInfo!.danmaku, + ), + ], + ), + ], ], - const Spacer(), - Text( - item.upper!.name!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - color: theme.colorScheme.outline, - ), - ), - if (item.type == 2) ...[ - const SizedBox(height: 3), - Row( - spacing: 8, - children: [ - StatWidget( - type: StatType.play, - value: item.cntInfo!.play, - ), - StatWidget( - type: StatType.danmaku, - value: item.cntInfo!.danmaku, - ), - ], - ), - ], - ], + ), ), - ), - ], + ], + ), ), - ), - if (showDelBtn && !isCurr) - Positioned( - right: 12, - bottom: -6, - child: InkWell( - customBorder: const CircleBorder(), - onTap: () => showConfirmDialog( - context: context, - title: '确定移除该视频?', - onConfirm: () => widget.onDelete!(item, index), - ), - onLongPress: () => widget.onDelete!(item, index), - child: Padding( - padding: const EdgeInsets.all(9), - child: Icon( - Icons.clear, - size: 18, - color: theme.colorScheme.outline, + if (showDelBtn && !isCurr) + Positioned( + right: 12, + bottom: -6, + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => showConfirmDialog( + context: context, + title: '确定移除该视频?', + onConfirm: () => widget.onDelete!(item, index), + ), + onLongPress: () => widget.onDelete!(item, index), + child: Padding( + padding: const EdgeInsets.all(9), + child: Icon( + Icons.clear, + size: 18, + color: theme.colorScheme.outline, + ), ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/pages/video/note/view.dart b/lib/pages/video/note/view.dart index 71206a84d..43b2f0308 100644 --- a/lib/pages/video/note/view.dart +++ b/lib/pages/video/note/view.dart @@ -36,14 +36,13 @@ class NoteListPage extends CommonSlidePage { State createState() => _NoteListPageState(); } -class _NoteListPageState extends CommonSlidePageState { +class _NoteListPageState extends State + with SingleTickerProviderStateMixin, CommonSlideMixin { late final _controller = Get.put( NoteListPageCtr(oid: widget.oid, upperMid: widget.upperMid), tag: widget.heroTag, ); - final _key = GlobalKey(); - @override void dispose() { Get.delete(tag: widget.heroTag); @@ -54,7 +53,6 @@ class _NoteListPageState extends CommonSlidePageState { Widget buildPage(ThemeData theme) { return Scaffold( resizeToAvoidBottomInset: false, - key: _key, body: Column( children: [ SizedBox( @@ -92,6 +90,14 @@ class _NoteListPageState extends CommonSlidePageState { ); } + late Key _key; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _key = ValueKey(PrimaryScrollController.of(context).hashCode); + } + @override Widget buildList(ThemeData theme) { return refreshIndicator( @@ -101,7 +107,7 @@ class _NoteListPageState extends CommonSlidePageState { children: [ Expanded( child: CustomScrollView( - controller: _controller.scrollController, + key: _key, physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverPadding( @@ -129,30 +135,32 @@ class _NoteListPageState extends CommonSlidePageState { ), ), ), - child: FilledButton.tonal( - style: FilledButton.styleFrom( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - padding: EdgeInsets.zero, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(6)), - ), - ), - onPressed: () { - if (!Accounts.main.isLogin) { - SmartDialog.showToast('账号未登录'); - return; - } - _key.currentState?.showBottomSheet( - constraints: const BoxConstraints(), - (context) => WebviewPage( - oid: widget.oid, - title: widget.title, - url: - 'https://www.bilibili.com/h5/note-app?oid=${widget.oid}&pagefrom=ugcvideo&is_stein_gate=${widget.isStein ? 1 : 0}', + child: Builder( + builder: (context) => FilledButton.tonal( + style: FilledButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: EdgeInsets.zero, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(6)), ), - ); - }, - child: const Text('开始记笔记'), + ), + onPressed: () { + if (!Accounts.main.isLogin) { + SmartDialog.showToast('账号未登录'); + return; + } + Scaffold.of(context).showBottomSheet( + constraints: const BoxConstraints(), + (context) => WebviewPage( + oid: widget.oid, + title: widget.title, + url: + 'https://www.bilibili.com/h5/note-app?oid=${widget.oid}&pagefrom=ugcvideo&is_stein_gate=${widget.isStein ? 1 : 0}', + ), + ); + }, + child: const Text('开始记笔记'), + ), ), ), ], @@ -169,15 +177,10 @@ class _NoteListPageState extends CommonSlidePageState { color: theme.colorScheme.outline.withValues(alpha: 0.1), ); return switch (loadingState) { - Loading() => SliverToBoxAdapter( - child: IgnorePointer( - child: ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) => const VideoReplySkeleton(), - itemCount: 8, - ), - ), + Loading() => SliverPrototypeExtentList.builder( + prototypeItem: const VideoReplySkeleton(), + itemBuilder: (_, _) => const VideoReplySkeleton(), + itemCount: 8, ), Success(:var response) => response?.isNotEmpty == true diff --git a/lib/pages/video/post_panel/view.dart b/lib/pages/video/post_panel/view.dart index eb4f0497e..c36c3ac07 100644 --- a/lib/pages/video/post_panel/view.dart +++ b/lib/pages/video/post_panel/view.dart @@ -10,7 +10,7 @@ import 'package:PiliPlus/models/common/sponsor_block/action_type.dart'; import 'package:PiliPlus/models/common/sponsor_block/post_segment_model.dart'; import 'package:PiliPlus/models/common/sponsor_block/segment_type.dart'; import 'package:PiliPlus/models_new/sponsor_block/segment_item.dart'; -import 'package:PiliPlus/pages/common/slide/common_collapse_slide_page.dart'; +import 'package:PiliPlus/pages/common/slide/common_slide_page.dart'; import 'package:PiliPlus/pages/video/controller.dart'; import 'package:PiliPlus/pages/video/post_panel/popup_menu_text.dart'; import 'package:PiliPlus/plugin/pl_player/controller.dart'; @@ -24,7 +24,7 @@ import 'package:flutter/services.dart' show FilteringTextInputFormatter; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart' hide Response; -class PostPanel extends CommonCollapseSlidePage { +class PostPanel extends CommonSlidePage { const PostPanel({ super.key, super.enableSlide, @@ -179,7 +179,8 @@ class PostPanel extends CommonCollapseSlidePage { } } -class _PostPanelState extends CommonCollapseSlidePageState { +class _PostPanelState extends State + with SingleTickerProviderStateMixin, CommonSlideMixin { late final VideoDetailController videoDetailController = widget.videoDetailController; late final PlPlayerController plPlayerController = widget.plPlayerController; @@ -191,14 +192,6 @@ class _PostPanelState extends CommonCollapseSlidePageState { double get currentPos => plPlayerController.position.value.inMilliseconds / 1000; - final _controller = ScrollController(); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - @override Widget buildPage(ThemeData theme) { return Scaffold( @@ -246,6 +239,14 @@ class _PostPanelState extends CommonCollapseSlidePageState { ); } + late Key _key; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _key = ValueKey(PrimaryScrollController.of(context).hashCode); + } + @override Widget buildList(ThemeData theme) { if (list.isNullOrEmpty) { @@ -256,7 +257,7 @@ class _PostPanelState extends CommonCollapseSlidePageState { clipBehavior: Clip.none, children: [ ListView.builder( - controller: _controller, + key: _key, physics: const AlwaysScrollableScrollPhysics(), padding: EdgeInsets.only(bottom: 88 + bottom), itemCount: list.length, diff --git a/lib/pages/video/related/view.dart b/lib/pages/video/related/view.dart index c11fb5f51..dc684d462 100644 --- a/lib/pages/video/related/view.dart +++ b/lib/pages/video/related/view.dart @@ -3,6 +3,7 @@ import 'package:PiliPlus/common/widgets/video_card/video_card_h.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models/model_hot_video_item.dart'; import 'package:PiliPlus/pages/video/related/controller.dart'; +import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/grid.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -14,19 +15,14 @@ class RelatedVideoPanel extends StatefulWidget { State createState() => _RelatedVideoPanelState(); } -class _RelatedVideoPanelState extends State - with AutomaticKeepAliveClientMixin, GridMixin { - late final RelatedController _relatedController = Get.put( - RelatedController(), +class _RelatedVideoPanelState extends State with GridMixin { + late final RelatedController _relatedController = Get.putOrFind( + RelatedController.new, tag: widget.heroTag, ); - @override - bool get wantKeepAlive => true; - @override Widget build(BuildContext context) { - super.build(context); return SliverPadding( padding: const EdgeInsets.only(top: 7, bottom: 100), sliver: Obx(() => _buildBody(_relatedController.loadingState.value)), diff --git a/lib/pages/video/reply/view.dart b/lib/pages/video/reply/view.dart index 8177e129b..da23d05ae 100644 --- a/lib/pages/video/reply/view.dart +++ b/lib/pages/video/reply/view.dart @@ -2,13 +2,16 @@ import 'package:PiliPlus/common/skeleton/video_reply.dart'; import 'package:PiliPlus/common/widgets/custom_sliver_persistent_header_delegate.dart'; import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; import 'package:PiliPlus/common/widgets/refresh_indicator.dart'; +import 'package:PiliPlus/common/widgets/scaffold/bottom_sheet.dart'; import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' show ReplyInfo; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/pages/video/reply/controller.dart'; import 'package:PiliPlus/pages/video/reply/widgets/reply_item_grpc.dart'; +import 'package:PiliPlus/pages/video/reply_reply/view.dart'; import 'package:PiliPlus/utils/feed_back.dart'; -import 'package:flutter/material.dart'; +import 'package:easy_debounce/easy_throttle.dart'; +import 'package:flutter/material.dart' hide showBottomSheet; import 'package:flutter/rendering.dart'; import 'package:get/get.dart'; @@ -17,20 +20,16 @@ class VideoReplyPanel extends StatefulWidget { super.key, this.replyLevel = 1, required this.heroTag, - required this.replyReply, this.onViewImage, this.onDismissed, - this.callback, - required this.needController, + required this.isNested, }); final int replyLevel; final String heroTag; - final Function(ReplyInfo replyItem, int? rpid) replyReply; final VoidCallback? onViewImage; final ValueChanged? onDismissed; - final Function(List, int)? callback; - final bool needController; + final bool isNested; @override State createState() => _VideoReplyPanelState(); @@ -58,17 +57,17 @@ class _VideoReplyPanelState extends State void didChangeDependencies() { super.didChangeDependencies(); _videoReplyController.showFab(); - if (widget.needController != false) { - _videoReplyController.scrollController.addListener(listener); - } else { + if (widget.isNested) { _videoReplyController.scrollController.removeListener(listener); + } else { + _videoReplyController.scrollController.addListener(listener); } bottom = MediaQuery.viewPaddingOf(context).bottom; } @override void dispose() { - if (widget.needController != false) { + if (!widget.isNested) { _videoReplyController.scrollController.removeListener(listener); } super.dispose(); @@ -100,14 +99,14 @@ class _VideoReplyPanelState extends State clipBehavior: Clip.none, children: [ CustomScrollView( - controller: widget.needController - ? _videoReplyController.scrollController - : null, - physics: widget.needController - ? const AlwaysScrollableScrollPhysics() - : const AlwaysScrollableScrollPhysics( + controller: widget.isNested + ? null + : _videoReplyController.scrollController, + physics: widget.isNested + ? const AlwaysScrollableScrollPhysics( parent: ClampingScrollPhysics(), - ), + ) + : const AlwaysScrollableScrollPhysics(), key: const PageStorageKey('评论'), slivers: [ SliverPersistentHeader( @@ -186,14 +185,17 @@ class _VideoReplyPanelState extends State ); } - Widget _buildBody(ThemeData theme, LoadingState loadingState) { + Widget _buildBody( + ThemeData theme, + LoadingState?> loadingState, + ) { return switch (loadingState) { Loading() => SliverList.builder( itemBuilder: (context, index) => const VideoReplySkeleton(), itemCount: 5, ), Success(:var response) => - response?.isNotEmpty == true + response != null && response.isNotEmpty ? SliverList.builder( itemBuilder: (context, index) { if (index == response.length) { @@ -215,7 +217,7 @@ class _VideoReplyPanelState extends State return ReplyItemGrpc( replyItem: response[index], replyLevel: widget.replyLevel, - replyReply: widget.replyReply, + replyReply: replyReply, onReply: (replyItem) => _videoReplyController.onReply( context, replyItem: replyItem, @@ -226,7 +228,6 @@ class _VideoReplyPanelState extends State getTag: () => heroTag, onViewImage: widget.onViewImage, onDismissed: widget.onDismissed, - callback: widget.callback, onCheckReply: (item) => _videoReplyController .onCheckReply(item, isManual: true), onToggleTop: (item) => _videoReplyController.onToggleTop( @@ -250,4 +251,28 @@ class _VideoReplyPanelState extends State ), }; } + + // 展示二级回复 + void replyReply(ReplyInfo replyItem, int? id) { + EasyThrottle.throttle('replyReply', const Duration(milliseconds: 500), () { + int oid = replyItem.oid.toInt(); + int rpid = replyItem.id.toInt(); + showBottomSheet( + context: context, + backgroundColor: Colors.transparent, + constraints: const BoxConstraints(), + builder: (context) => VideoReplyReplyPanel( + id: id, + oid: oid, + rpid: rpid, + firstFloor: replyItem, + replyType: _videoReplyController.videoType.replyType, + isVideoDetail: true, + onViewImage: widget.onViewImage, + onDismissed: widget.onDismissed, + isNested: widget.isNested, + ), + ); + }); + } } diff --git a/lib/pages/video/reply/widgets/reply_item_grpc.dart b/lib/pages/video/reply/widgets/reply_item_grpc.dart index a35feeaf9..9074bc711 100644 --- a/lib/pages/video/reply/widgets/reply_item_grpc.dart +++ b/lib/pages/video/reply/widgets/reply_item_grpc.dart @@ -51,9 +51,9 @@ class ReplyItemGrpc extends StatelessWidget { this.getTag, this.onViewImage, this.onDismissed, - this.callback, this.onCheckReply, this.onToggleTop, + this.jumpToDialogue, }); final ReplyInfo replyItem; final int replyLevel; @@ -66,9 +66,9 @@ class ReplyItemGrpc extends StatelessWidget { final Function? getTag; final VoidCallback? onViewImage; final ValueChanged? onDismissed; - final Function(List, int)? callback; final ValueChanged? onCheckReply; final ValueChanged? onToggleTop; + final VoidCallback? jumpToDialogue; static final _voteRegExp = RegExp(r"^\{vote:\d+?\}$"); static final _timeRegExp = RegExp(r'^\b(?:\d+[::])?\d+[::]\d+\b$'); @@ -312,7 +312,6 @@ class ReplyItemGrpc extends StatelessWidget { .toList(), onViewImage: onViewImage, onDismissed: onDismissed, - callback: callback, ), ), ), @@ -416,6 +415,24 @@ class ReplyItemGrpc extends StatelessWidget { ), ), ), + ) + else if (replyLevel == 3 && + needDivider && + replyItem.parent != replyItem.root) + SizedBox( + height: 32, + child: TextButton( + onPressed: jumpToDialogue, + style: style, + child: Text( + '跳转回复', + style: TextStyle( + color: theme.colorScheme.outline, + fontSize: theme.textTheme.labelMedium!.fontSize, + fontWeight: FontWeight.normal, + ), + ), + ), ), const Spacer(), ZanButtonGrpc(replyItem: replyItem), diff --git a/lib/pages/video/reply_reply/controller.dart b/lib/pages/video/reply_reply/controller.dart index 4ff3cec07..e234326bc 100644 --- a/lib/pages/video/reply_reply/controller.dart +++ b/lib/pages/video/reply_reply/controller.dart @@ -7,8 +7,11 @@ import 'package:PiliPlus/pages/video/reply_new/view.dart'; import 'package:PiliPlus/utils/id_utils.dart'; import 'package:PiliPlus/utils/request_utils.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; +import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; import 'package:fixnum/fixnum.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:get/get.dart'; import 'package:get/get_navigation/src/dialog/dialog_route.dart'; import 'package:super_sliver_list/super_sliver_list.dart'; @@ -22,10 +25,8 @@ class VideoReplyReplyController extends ReplyController required this.rpid, required this.dialog, required this.replyType, - required this.isDialogue, }); final int? dialog; - final bool isDialogue; int? id; // 视频aid 请求时使用的oid int oid; @@ -34,12 +35,18 @@ class VideoReplyReplyController extends ReplyController int replyType; bool hasRoot = false; - late final Rx firstFloor = Rx(null); + final Rx firstFloor = Rx(null); + + final index = RxnInt(); - int? index; - AnimationController? animController; final listController = ListController(); + AnimationController? _controller; + AnimationController get animController => _controller ??= AnimationController( + duration: const Duration(milliseconds: 1000), + vsync: this, + ); + late final horizontalPreview = Pref.horizontalPreview; @override @@ -54,7 +61,7 @@ class VideoReplyReplyController extends ReplyController @override List? getDataList(response) { - return isDialogue ? response.replies : response.root.replies; + return dialog != null ? response.replies : response.root.replies; } @override @@ -73,29 +80,7 @@ class VideoReplyReplyController extends ReplyController firstFloor.value ??= data.root; } if (id != null) { - final id64 = Int64(id!); - final index = data.root.replies.indexWhere((item) => item.id == id64); - if (index != -1) { - this.index = index; - animController = AnimationController( - duration: const Duration(milliseconds: 300), - vsync: this, - ); - WidgetsBinding.instance.addPostFrameCallback((_) async { - try { - listController.jumpToItem( - index: index, - scrollController: scrollController, - alignment: 0.25, - ); - await Future.delayed( - const Duration(milliseconds: 800), - animController?.forward, - ); - this.index = null; - } catch (_) {} - }); - } + setIndexById(Int64(id!), data.root.replies); id = null; } } @@ -103,8 +88,41 @@ class VideoReplyReplyController extends ReplyController return false; } + bool setIndexById(Int64 id64, [List? replies]) { + final index = (replies ?? loadingState.value.data!).indexWhere( + (item) => item.id == id64, + ); + if (index != -1) { + this.index.value = index; + jumpToItem(index); + return true; + } + return false; + } + + ExtendedNestedScrollController? nestedController; + + void jumpToItem(int index) { + SchedulerBinding.instance.addPostFrameCallback((_) { + animController.forward(from: 0); + try { + // ignore: invalid_use_of_visible_for_testing_member + final offset = listController.getOffsetToReveal(index, 0.25); + if (offset.isFinite) { + if (nestedController case final nestedController?) { + nestedController.nestedPositions.last.localJumpTo(offset); + } else { + scrollController.jumpTo(offset); + } + } + } catch (_) { + if (kDebugMode) rethrow; + } + }); + } + @override - Future customGetData() => isDialogue + Future customGetData() => dialog != null ? ReplyGrpc.dialogList( type: replyType, oid: oid, @@ -129,6 +147,14 @@ class VideoReplyReplyController extends ReplyController onReload(); } + @override + Future onReload() { + if (loadingState.value.isSuccess) { + index.value = null; + } + return super.onReload(); + } + @override void onReply( BuildContext context, { @@ -201,8 +227,8 @@ class VideoReplyReplyController extends ReplyController @override void onClose() { - animController?.dispose(); - listController.dispose(); + _controller?.dispose(); + _controller = null; super.dispose(); } } diff --git a/lib/pages/video/reply_reply/view.dart b/lib/pages/video/reply_reply/view.dart index 6a6dd76fe..8ace63c06 100644 --- a/lib/pages/video/reply_reply/view.dart +++ b/lib/pages/video/reply_reply/view.dart @@ -2,17 +2,18 @@ import 'package:PiliPlus/common/skeleton/video_reply.dart'; import 'package:PiliPlus/common/widgets/custom_sliver_persistent_header_delegate.dart'; import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; import 'package:PiliPlus/common/widgets/refresh_indicator.dart'; +import 'package:PiliPlus/common/widgets/scaffold/scaffold.dart'; import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' show ReplyInfo, Mode; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/pages/common/slide/common_slide_page.dart'; import 'package:PiliPlus/pages/video/reply/widgets/reply_item_grpc.dart'; import 'package:PiliPlus/pages/video/reply_reply/controller.dart'; -import 'package:PiliPlus/utils/context_ext.dart'; import 'package:PiliPlus/utils/num_utils.dart'; -import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; +import 'package:flutter/material.dart' hide Scaffold, ScaffoldState; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart' hide ContextExtensionss; import 'package:super_sliver_list/super_sliver_list.dart'; @@ -27,9 +28,9 @@ class VideoReplyReplyPanel extends CommonSlidePage { this.firstFloor, required this.isVideoDetail, required this.replyType, - this.isDialogue = false, this.onViewImage, this.onDismissed, + this.isNested = false, }); final int? id; final int oid; @@ -38,28 +39,33 @@ class VideoReplyReplyPanel extends CommonSlidePage { final ReplyInfo? firstFloor; final bool isVideoDetail; final int replyType; - final bool isDialogue; final VoidCallback? onViewImage; final ValueChanged? onDismissed; + final bool isNested; @override State createState() => _VideoReplyReplyPanelState(); } -class _VideoReplyReplyPanelState - extends CommonSlidePageState { +class _VideoReplyReplyPanelState extends State + with SingleTickerProviderStateMixin, CommonSlideMixin { late VideoReplyReplyController _controller; - late final _key = GlobalKey(); - late final _tag = Utils.makeHeroTag( - '${widget.rpid}${widget.dialog}${widget.isDialogue}', - ); - - bool get _horizontalPreview => - _controller.horizontalPreview && context.isLandscape; - Function(List imgList, int index)? _imageCallback; - + late final _tag = Utils.makeHeroTag('${widget.rpid}${widget.dialog}'); Animation? colorAnimation; + late final bool isDialogue = widget.dialog != null; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final controller = PrimaryScrollController.of(context); + _controller + ..didChangeDependencies(context) + ..nestedController = controller is ExtendedNestedScrollController + ? controller + : null; + } + @override void initState() { super.initState(); @@ -71,7 +77,6 @@ class _VideoReplyReplyPanelState rpid: widget.rpid, dialog: widget.dialog, replyType: widget.replyType, - isDialogue: widget.isDialogue, ), tag: _tag, ); @@ -83,24 +88,10 @@ class _VideoReplyReplyPanelState super.dispose(); } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _imageCallback = _horizontalPreview - ? (imgList, index) => PageUtils.onHorizontalPreview( - _key, - this, - imgList, - index, - ) - : null; - } - @override Widget buildPage(ThemeData theme) { Widget child() => enableSlide ? slideList(theme) : buildList(theme); return Scaffold( - key: _key, resizeToAvoidBottomInset: false, body: widget.isVideoDetail ? Column( @@ -119,7 +110,7 @@ class _VideoReplyReplyPanelState child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(widget.isDialogue ? '对话列表' : '评论详情'), + Text(isDialogue ? '对话列表' : '评论详情'), IconButton( tooltip: '关闭', icon: const Icon(Icons.close, size: 20), @@ -138,14 +129,23 @@ class _VideoReplyReplyPanelState ReplyInfo? get firstFloor => widget.firstFloor ?? _controller.firstFloor.value; + ScrollController get scrollController => + _controller.nestedController ?? _controller.scrollController; + @override Widget buildList(ThemeData theme) { return refreshIndicator( onRefresh: _controller.onRefresh, child: CustomScrollView( - controller: _controller.scrollController, + key: ValueKey(scrollController.hashCode), + controller: scrollController, + physics: widget.isNested + ? const AlwaysScrollableScrollPhysics( + parent: ClampingScrollPhysics(), + ) + : const AlwaysScrollableScrollPhysics(), slivers: [ - if (!widget.isDialogue) ...[ + if (!isDialogue) ...[ if (widget.firstFloor case final firstFloor?) _header(theme, firstFloor) else @@ -180,7 +180,6 @@ class _VideoReplyReplyPanelState upMid: _controller.upMid, onViewImage: widget.onViewImage, onDismissed: widget.onDismissed, - callback: _imageCallback, onCheckReply: (item) => _controller.onCheckReply(item, isManual: true), ), @@ -252,18 +251,14 @@ class _VideoReplyReplyPanelState ThemeData theme, LoadingState?> loadingState, ) { + final jumpIndex = _controller.index.value; return switch (loadingState) { - Loading() => SliverToBoxAdapter( - child: IgnorePointer( - child: ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) => const VideoReplySkeleton(), - itemCount: 8, - ), - ), + Loading() => SliverPrototypeExtentList.builder( + prototypeItem: const VideoReplySkeleton(), + itemBuilder: (_, _) => const VideoReplySkeleton(), + itemCount: 8, ), - Success(:var response) => SuperSliverList.builder( + Success(:var response!) => SuperSliverList.builder( listController: _controller.listController, itemBuilder: (context, index) { if (index == response.length) { @@ -284,19 +279,23 @@ class _VideoReplyReplyPanelState ), ); } - final child = _replyItem(response[index], index); - if (_controller.index == index) { - colorAnimation ??= ColorTween( - begin: theme.colorScheme.onInverseSurface, - end: theme.colorScheme.surface, - ).animate(_controller.animController!); + final child = _replyItem(context, response[index], index); + if (jumpIndex == index) { return AnimatedBuilder( - animation: colorAnimation!, - builder: (context, _) { + animation: colorAnimation ??= + ColorTween( + begin: theme.colorScheme.onInverseSurface, + end: theme.colorScheme.surface, + ).animate( + CurvedAnimation( + parent: _controller.animController, + curve: const Interval(0.8, 1.0), // 前0.8s不变, 后0.2s开始动画 + ), + ), + child: child, + builder: (context, child) { return ColoredBox( - color: - colorAnimation!.value ?? - theme.colorScheme.onInverseSurface, + color: colorAnimation!.value!, child: child, ); }, @@ -304,7 +303,7 @@ class _VideoReplyReplyPanelState } return child; }, - itemCount: response!.length + 1, + itemCount: response.length + 1, ), Error(:var errMsg) => HttpError( errMsg: errMsg, @@ -313,15 +312,15 @@ class _VideoReplyReplyPanelState }; } - Widget _replyItem(ReplyInfo replyItem, int index) { + Widget _replyItem(BuildContext context, ReplyInfo replyItem, int index) { return ReplyItemGrpc( replyItem: replyItem, - replyLevel: widget.isDialogue ? 3 : 2, + replyLevel: isDialogue ? 3 : 2, onReply: (replyItem) => - _controller.onReply(context, replyItem: replyItem, index: index), + _controller.onReply(this.context, replyItem: replyItem, index: index), onDelete: (item, subIndex) => _controller.onRemove(index, item, null), upMid: _controller.upMid, - showDialogue: () => _key.currentState?.showBottomSheet( + showDialogue: () => Scaffold.of(context).showBottomSheet( backgroundColor: Colors.transparent, constraints: const BoxConstraints(), (context) => VideoReplyReplyPanel( @@ -330,12 +329,16 @@ class _VideoReplyReplyPanelState dialog: replyItem.dialog.toInt(), replyType: widget.replyType, isVideoDetail: true, - isDialogue: true, + isNested: widget.isNested, ), ), + jumpToDialogue: () { + if (!_controller.setIndexById(replyItem.parent)) { + SmartDialog.showToast('评论可能已被删除'); + } + }, onViewImage: widget.onViewImage, onDismissed: widget.onDismissed, - callback: _imageCallback, onCheckReply: (item) => _controller.onCheckReply(item, isManual: true), ); } diff --git a/lib/pages/video/view.dart b/lib/pages/video/view.dart index b837e6eb3..1a047ef67 100644 --- a/lib/pages/video/view.dart +++ b/lib/pages/video/view.dart @@ -6,9 +6,9 @@ import 'dart:ui'; import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/custom_icon.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/common/widgets/keep_alive_wrapper.dart'; +import 'package:PiliPlus/common/widgets/scaffold/scaffold.dart'; import 'package:PiliPlus/common/widgets/scroll_physics.dart'; -import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' - show ReplyInfo; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/main.dart'; import 'package:PiliPlus/models/common/episode_panel_type.dart'; @@ -33,7 +33,6 @@ import 'package:PiliPlus/pages/video/member/view.dart'; import 'package:PiliPlus/pages/video/related/view.dart'; import 'package:PiliPlus/pages/video/reply/controller.dart'; import 'package:PiliPlus/pages/video/reply/view.dart'; -import 'package:PiliPlus/pages/video/reply_reply/view.dart'; import 'package:PiliPlus/pages/video/view_point/view.dart'; import 'package:PiliPlus/pages/video/widgets/focus.dart'; import 'package:PiliPlus/pages/video/widgets/header_control.dart'; @@ -54,11 +53,10 @@ import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/storage_key.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:auto_orientation/auto_orientation.dart'; -import 'package:easy_debounce/easy_throttle.dart'; import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; import 'package:floating/floating.dart'; import 'package:flutter/foundation.dart' show kDebugMode; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide Scaffold; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart' show SystemUiOverlayStyle; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -117,15 +115,10 @@ class _VideoDetailPageVState extends State ((videoDetail.pages?.length ?? 0) > 1)); } - bool get _horizontalPreview => - !isPortrait && videoDetailController.plPlayerController.horizontalPreview; - - final GlobalKey relatedVideoPanelKey = GlobalKey(); - final GlobalKey videoPlayerKey = GlobalKey(); - final GlobalKey playerKey = GlobalKey(); - final GlobalKey videoReplyPanelKey = GlobalKey(); - late final GlobalKey ugcPanelKey = GlobalKey(); - late final GlobalKey pgcPanelKey = GlobalKey(); + final videoPlayerKey = GlobalKey(); + final videoReplyPanelKey = GlobalKey(); + final videoRelatedKey = GlobalKey(); + final videoIntroKey = GlobalKey(); @override void initState() { @@ -470,8 +463,6 @@ class _VideoDetailPageVState extends State plPlayerController?.addPositionListener(positionListener); } - Function(List imgList, int index)? _imageCallback; - @override void didChangeDependencies() { super.didChangeDependencies(); @@ -498,14 +489,6 @@ class _VideoDetailPageVState extends State themeData = videoDetailController.plPlayerController.darkVideoPage ? MyApp.darkThemeData ?? Theme.of(context) : Theme.of(context); - _imageCallback = _horizontalPreview - ? (imgList, index) => PageUtils.onHorizontalPreview( - videoDetailController.childKey, - this, - imgList, - index, - ) - : null; } void animListener() { @@ -580,7 +563,6 @@ class _VideoDetailPageVState extends State final isFullScreen = this.isFullScreen; return Scaffold( resizeToAvoidBottomInset: false, - key: videoDetailController.scaffoldKey, appBar: isFullScreen ? null : PreferredSize( @@ -631,9 +613,6 @@ class _VideoDetailPageVState extends State ), body: ExtendedNestedScrollView( key: videoDetailController.scrollKey, - physics: const NeverScrollableScrollPhysics( - parent: ClampingScrollPhysics(), - ), controller: videoDetailController.scrollCtr, onlyOneScrollInBody: true, pinnedHeaderSliverHeightBuilder: () { @@ -972,7 +951,7 @@ class _VideoDetailPageVState extends State children: [ videoIntro(isHorizontal: false, needCtr: false), if (videoDetailController.showReply) - videoReplyPanel(false), + videoReplyPanel(isNested: true), if (_shouldShowSeasonPanel) seasonPanel, ], ), @@ -992,7 +971,6 @@ class _VideoDetailPageVState extends State final padding = MediaQuery.viewPaddingOf(context); return Scaffold( resizeToAvoidBottomInset: false, - key: videoDetailController.scaffoldKey, appBar: isFullScreen ? null : AppBar(backgroundColor: Colors.black, toolbarHeight: 0), @@ -1137,14 +1115,16 @@ class _VideoDetailPageVState extends State videoDetailController .plPlayerController .showRelatedVideo) - CustomScrollView( - controller: introScrollController, - slivers: [ - RelatedVideoPanel( - key: relatedVideoPanelKey, - heroTag: heroTag, - ), - ], + KeepAliveWrapper( + builder: (context) => CustomScrollView( + controller: introScrollController, + slivers: [ + RelatedVideoPanel( + key: videoRelatedKey, + heroTag: heroTag, + ), + ], + ), ), if (videoDetailController.showReply) videoReplyPanel(), if (_shouldShowSeasonPanel) seasonPanel, @@ -1165,7 +1145,6 @@ class _VideoDetailPageVState extends State final padding = MediaQuery.viewPaddingOf(context); return Scaffold( resizeToAvoidBottomInset: false, - key: videoDetailController.scaffoldKey, appBar: isFullScreen ? null : AppBar(backgroundColor: Colors.black, toolbarHeight: 0), @@ -1449,7 +1428,6 @@ class _VideoDetailPageVState extends State plPlayerController?.videoController == null ? const SizedBox.shrink() : PLVideoPlayer( - key: playerKey, maxWidth: width, maxHeight: height, plPlayerController: plPlayerController!, @@ -1833,64 +1811,69 @@ class _VideoDetailPageVState extends State bool needCtr = true, }) { final bottom = MediaQuery.viewPaddingOf(context).bottom; - Widget introPanel() => CustomScrollView( - key: const PageStorageKey('简介'), - controller: needCtr ? introScrollController : null, - physics: !needCtr - ? const AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics()) - : null, - slivers: [ - if (videoDetailController.isUgc) ...[ - UgcIntroPanel( - key: ugcPanelKey, - heroTag: heroTag, - showAiBottomSheet: showAiBottomSheet, - showEpisodes: showEpisodes, - onShowMemberPage: onShowMemberPage, - isPortrait: isPortrait, - isHorizontal: isHorizontal ?? width! > height! * 1.25, - ), - if (needRelated && - videoDetailController.plPlayerController.showRelatedVideo) ...[ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(top: StyleString.safeSpace), - child: Divider( - height: 1, - indent: 12, - endIndent: 12, - color: themeData.colorScheme.outline.withValues(alpha: 0.08), + Widget introPanel() => KeepAliveWrapper( + builder: (context) => CustomScrollView( + controller: needCtr ? introScrollController : null, + physics: !needCtr + ? const AlwaysScrollableScrollPhysics( + parent: ClampingScrollPhysics(), + ) + : null, + slivers: [ + if (videoDetailController.isUgc) ...[ + UgcIntroPanel( + key: videoIntroKey, + heroTag: heroTag, + showAiBottomSheet: showAiBottomSheet, + showEpisodes: showEpisodes, + onShowMemberPage: onShowMemberPage, + isPortrait: isPortrait, + isHorizontal: isHorizontal ?? width! > height! * 1.25, + ), + if (needRelated && + videoDetailController.plPlayerController.showRelatedVideo) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: StyleString.safeSpace), + child: Divider( + height: 1, + indent: 12, + endIndent: 12, + color: themeData.colorScheme.outline.withValues( + alpha: 0.08, + ), + ), ), ), + RelatedVideoPanel(key: videoRelatedKey, heroTag: heroTag), + ], + ] else + PgcIntroPage( + key: videoIntroKey, + heroTag: heroTag, + cid: videoDetailController.cid.value, + showEpisodes: showEpisodes, + showIntroDetail: showIntroDetail, + maxWidth: width ?? maxWidth, + isLandscape: !isPortrait, + ), + SliverToBoxAdapter( + child: SizedBox( + height: + (videoDetailController.isPlayAll && !isPortrait + ? 80 + : StyleString.safeSpace) + + bottom, ), - RelatedVideoPanel(key: relatedVideoPanelKey, heroTag: heroTag), - ], - ] else - PgcIntroPage( - key: pgcPanelKey, - heroTag: heroTag, - cid: videoDetailController.cid.value, - showEpisodes: showEpisodes, - showIntroDetail: showIntroDetail, - maxWidth: width ?? maxWidth, - isLandscape: !isPortrait, ), - SliverToBoxAdapter( - child: SizedBox( - height: - (videoDetailController.isPlayAll && !isPortrait - ? 80 - : StyleString.safeSpace) + - bottom, - ), - ), - ], + ], + ), ); - return Stack( - clipBehavior: Clip.none, - children: [ - introPanel(), - if (videoDetailController.isPlayAll) + if (videoDetailController.isPlayAll) { + return Stack( + clipBehavior: Clip.none, + children: [ + introPanel(), Positioned( left: 12, right: 12, @@ -1928,29 +1911,74 @@ class _VideoDetailPageVState extends State ), ), ), - ) - else if (Platform.isAndroid) - const SizedBox.shrink(), - ], - ); + ), + ], + ); + } + return introPanel(); } Widget get seasonPanel { final videoDetail = ugcIntroController.videoDetail.value; - return Column( - children: [ - if ((videoDetail.pages?.length ?? 0) > 1) - if (videoDetail.ugcSeason != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: PagesPanel( - heroTag: heroTag, - ugcIntroController: ugcIntroController, - bvid: ugcIntroController.bvid, - showEpisodes: showEpisodes, + return KeepAliveWrapper( + builder: (context) => Column( + children: [ + if ((videoDetail.pages?.length ?? 0) > 1) + if (videoDetail.ugcSeason != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: PagesPanel( + heroTag: heroTag, + ugcIntroController: ugcIntroController, + bvid: ugcIntroController.bvid, + showEpisodes: showEpisodes, + ), + ) + else + Expanded( + child: Obx( + () => EpisodePanel( + heroTag: heroTag, + enableSlide: false, + ugcIntroController: videoDetailController.isUgc + ? ugcIntroController + : null, + type: EpisodeType.part, + list: [videoDetail.pages!], + cover: videoDetailController.cover.value, + bvid: videoDetailController.bvid, + aid: videoDetailController.aid, + cid: videoDetailController.cid.value, + isReversed: videoDetail.isPageReversed, + onChangeEpisode: videoDetailController.isUgc + ? ugcIntroController.onChangeEpisode + : pgcIntroController.onChangeEpisode, + showTitle: false, + isSupportReverse: videoDetailController.isUgc, + onReverse: () => onReversePlay(isSeason: false), + ), + ), ), - ) - else + if (videoDetail.ugcSeason != null) ...[ + if ((videoDetail.pages?.length ?? 0) > 1) ...[ + const SizedBox(height: 8), + Divider( + height: 1, + color: themeData.colorScheme.outline.withValues(alpha: 0.1), + ), + ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Obx( + () => SeasonPanel( + key: ValueKey(introController.videoDetail.value), + heroTag: heroTag, + canTap: false, + showEpisodes: showEpisodes, + ugcIntroController: ugcIntroController, + ), + ), + ), Expanded( child: Obx( () => EpisodePanel( @@ -1959,110 +1987,43 @@ class _VideoDetailPageVState extends State ugcIntroController: videoDetailController.isUgc ? ugcIntroController : null, - type: EpisodeType.part, - list: [videoDetail.pages!], + type: EpisodeType.season, + initialTabIndex: videoDetailController.seasonIndex.value, cover: videoDetailController.cover.value, + seasonId: videoDetail.ugcSeason!.id, + list: videoDetail.ugcSeason!.sections!, bvid: videoDetailController.bvid, aid: videoDetailController.aid, - cid: videoDetailController.cid.value, - isReversed: videoDetail.isPageReversed, + cid: videoDetailController.seasonCid ?? 0, + isReversed: ugcIntroController + .videoDetail + .value + .ugcSeason! + .sections![videoDetailController.seasonIndex.value] + .isReversed, onChangeEpisode: videoDetailController.isUgc ? ugcIntroController.onChangeEpisode : pgcIntroController.onChangeEpisode, showTitle: false, isSupportReverse: videoDetailController.isUgc, - onReverse: () => onReversePlay(isSeason: false), + onReverse: () => onReversePlay(isSeason: true), ), ), ), - if (videoDetail.ugcSeason != null) ...[ - if ((videoDetail.pages?.length ?? 0) > 1) ...[ - const SizedBox(height: 8), - Divider( - height: 1, - color: themeData.colorScheme.outline.withValues(alpha: 0.1), - ), ], - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Obx( - () => SeasonPanel( - key: ValueKey(introController.videoDetail.value), - heroTag: heroTag, - canTap: false, - showEpisodes: showEpisodes, - ugcIntroController: ugcIntroController, - ), - ), - ), - Expanded( - child: Obx( - () => EpisodePanel( - heroTag: heroTag, - enableSlide: false, - ugcIntroController: videoDetailController.isUgc - ? ugcIntroController - : null, - type: EpisodeType.season, - initialTabIndex: videoDetailController.seasonIndex.value, - cover: videoDetailController.cover.value, - seasonId: videoDetail.ugcSeason!.id, - list: videoDetail.ugcSeason!.sections!, - bvid: videoDetailController.bvid, - aid: videoDetailController.aid, - cid: videoDetailController.seasonCid ?? 0, - isReversed: ugcIntroController - .videoDetail - .value - .ugcSeason! - .sections![videoDetailController.seasonIndex.value] - .isReversed, - onChangeEpisode: videoDetailController.isUgc - ? ugcIntroController.onChangeEpisode - : pgcIntroController.onChangeEpisode, - showTitle: false, - isSupportReverse: videoDetailController.isUgc, - onReverse: () => onReversePlay(isSeason: true), - ), - ), - ), ], - ], + ), ); } - Widget videoReplyPanel([bool needCtr = true]) => VideoReplyPanel( + Widget videoReplyPanel({bool isNested = false}) => VideoReplyPanel( key: videoReplyPanelKey, - needController: needCtr, + isNested: isNested, heroTag: heroTag, - replyReply: replyReply, onViewImage: videoDetailController.onViewImage, onDismissed: videoDetailController.onDismissed, - callback: _imageCallback, ); - // 展示二级回复 - void replyReply(ReplyInfo replyItem, int? id) { - EasyThrottle.throttle('replyReply', const Duration(milliseconds: 500), () { - int oid = replyItem.oid.toInt(); - int rpid = replyItem.id.toInt(); - videoDetailController.childKey.currentState?.showBottomSheet( - backgroundColor: Colors.transparent, - constraints: const BoxConstraints(), - (context) => VideoReplyReplyPanel( - id: id, - oid: oid, - rpid: rpid, - firstFloor: replyItem, - replyType: _videoReplyController.videoType.replyType, - isVideoDetail: true, - onViewImage: videoDetailController.onViewImage, - onDismissed: videoDetailController.onDismissed, - ), - ); - }); - } - // ai总结 void showAiBottomSheet() { videoDetailController.childKey.currentState?.showBottomSheet( diff --git a/lib/pages/video/view_point/view.dart b/lib/pages/video/view_point/view.dart index 801db6803..db12d3cfd 100644 --- a/lib/pages/video/view_point/view.dart +++ b/lib/pages/video/view_point/view.dart @@ -2,14 +2,14 @@ import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/button/icon_button.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/progress_bar/segment_progress_bar.dart'; -import 'package:PiliPlus/pages/common/slide/common_collapse_slide_page.dart'; +import 'package:PiliPlus/pages/common/slide/common_slide_page.dart'; import 'package:PiliPlus/pages/video/controller.dart'; import 'package:PiliPlus/plugin/pl_player/controller.dart'; import 'package:PiliPlus/utils/duration_utils.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -class ViewPointsPage extends CommonCollapseSlidePage { +class ViewPointsPage extends CommonSlidePage { const ViewPointsPage({ super.key, super.enableSlide, @@ -24,22 +24,14 @@ class ViewPointsPage extends CommonCollapseSlidePage { State createState() => _ViewPointsPageState(); } -class _ViewPointsPageState - extends CommonCollapseSlidePageState { +class _ViewPointsPageState extends State + with SingleTickerProviderStateMixin, CommonSlideMixin { VideoDetailController get videoDetailController => widget.videoDetailController; PlPlayerController? get plPlayerController => widget.plPlayerController; int currentIndex = -1; - final _controller = ScrollController(); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - @override Widget buildPage(ThemeData theme) { return Scaffold( @@ -93,10 +85,18 @@ class _ViewPointsPageState ); } + late Key _key; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _key = ValueKey(PrimaryScrollController.of(context).hashCode); + } + @override Widget buildList(ThemeData theme) { return ListView.builder( - controller: _controller, + key: _key, physics: const AlwaysScrollableScrollPhysics(), padding: EdgeInsets.only( top: 7, diff --git a/lib/pages/video/widgets/header_control.dart b/lib/pages/video/widgets/header_control.dart index 50ed95028..eeafbf7e9 100644 --- a/lib/pages/video/widgets/header_control.dart +++ b/lib/pages/video/widgets/header_control.dart @@ -86,7 +86,7 @@ class HeaderControlState extends TripleState { bool get isFullScreen => plPlayerController.isFullScreen.value; Box setting = GStorage.setting; - late final provider = ContextSingleTicker(context); + late final provider = ContextSingleTicker(context, autoStart: false); @override void initState() { diff --git a/lib/plugin/pl_player/view.dart b/lib/plugin/pl_player/view.dart index bf2aee37d..460e31fbb 100644 --- a/lib/plugin/pl_player/view.dart +++ b/lib/plugin/pl_player/view.dart @@ -192,8 +192,12 @@ class _PLVideoPlayerState extends State super.initState(); _controlsListener = plPlayerController.showControls.listen((bool val) { final visible = val && !plPlayerController.controlsLock.value; - widget.videoDetailController?.headerCtrKey.currentState?.provider.muted = - !visible; + if (widget.videoDetailController?.headerCtrKey.currentState?.provider + case final provider?) { + provider + ..startIfNeeded() + ..muted = !visible; + } if (visible) { animationController.forward(); } else { @@ -1133,19 +1137,11 @@ class _PLVideoPlayerState extends State switch (key) { case LogicalKeyboardKey.space: onDoubleTapCenter(); - break; + return; case LogicalKeyboardKey.keyF: plPlayerController.triggerFullScreen(status: !isFullScreen); - break; - - case LogicalKeyboardKey.arrowLeft when (!plPlayerController.isLive): - onDoubleTapSeekBackward(); - break; - - case LogicalKeyboardKey.arrowRight when (!plPlayerController.isLive): - onDoubleTapSeekForward(); - break; + return; case LogicalKeyboardKey.escape: if (isFullScreen) { @@ -1153,7 +1149,7 @@ class _PLVideoPlayerState extends State } else { Get.back(); } - break; + return; case LogicalKeyboardKey.keyD: final newVal = !plPlayerController.enableShowDanmaku.value; @@ -1161,7 +1157,7 @@ class _PLVideoPlayerState extends State if (!plPlayerController.tempPlayerConf) { GStorage.setting.put(SettingBoxKey.enableShowDanmaku, newVal); } - break; + return; case LogicalKeyboardKey.arrowUp: final volume = math.min( @@ -1169,7 +1165,7 @@ class _PLVideoPlayerState extends State plPlayerController.volume.value + 0.1, ); setVolume(volume); - break; + return; case LogicalKeyboardKey.arrowDown: final volume = math.max( @@ -1177,7 +1173,7 @@ class _PLVideoPlayerState extends State plPlayerController.volume.value - 0.1, ); setVolume(volume); - break; + return; case LogicalKeyboardKey.keyM: final isMuted = !plPlayerController.isMuted; @@ -1186,45 +1182,57 @@ class _PLVideoPlayerState extends State ); plPlayerController.isMuted = isMuted; SmartDialog.showToast('${isMuted ? '' : '取消'}静音'); - break; + return; + } - case LogicalKeyboardKey.keyQ when (!plPlayerController.isLive): - introController.actionLikeVideo(); - break; + if (!plPlayerController.isLive) { + switch (key) { + case LogicalKeyboardKey.arrowLeft: + onDoubleTapSeekBackward(); + return; - case LogicalKeyboardKey.keyW when (!plPlayerController.isLive): - introController.actionCoinVideo(); - break; + case LogicalKeyboardKey.arrowRight: + onDoubleTapSeekForward(); + return; - case LogicalKeyboardKey.keyE when (!plPlayerController.isLive): - introController.actionFavVideo(isQuick: true); - break; + case LogicalKeyboardKey.keyQ: + introController.actionLikeVideo(); + return; - case LogicalKeyboardKey.keyR when (!plPlayerController.isLive): - introController.viewLater(); - break; + case LogicalKeyboardKey.keyW: + introController.actionCoinVideo(); + return; - case LogicalKeyboardKey.keyG when (!plPlayerController.isLive): - if (introController case UgcIntroController ugcCtr) { - ugcCtr.actionRelationMod(context); - } - break; + case LogicalKeyboardKey.keyE: + introController.actionFavVideo(isQuick: true); + return; - case LogicalKeyboardKey.bracketLeft when (!plPlayerController.isLive): - if (!introController.prevPlay()) { - SmartDialog.showToast('已经是第一集了'); - } - break; + case LogicalKeyboardKey.keyR: + introController.viewLater(); + return; - case LogicalKeyboardKey.bracketRight when (!plPlayerController.isLive): - if (!introController.nextPlay()) { - SmartDialog.showToast('已经是最后一集了'); - } - break; + case LogicalKeyboardKey.keyG: + if (introController case UgcIntroController ugcCtr) { + ugcCtr.actionRelationMod(context); + } + return; - case LogicalKeyboardKey.enter when (!plPlayerController.isLive): - widget.videoDetailController?.showShootDanmakuSheet(); - break; + case LogicalKeyboardKey.bracketLeft: + if (!introController.prevPlay()) { + SmartDialog.showToast('已经是第一集了'); + } + return; + + case LogicalKeyboardKey.bracketRight: + if (!introController.nextPlay()) { + SmartDialog.showToast('已经是最后一集了'); + } + return; + + case LogicalKeyboardKey.enter: + widget.videoDetailController?.showShootDanmakuSheet(); + return; + } } } } @@ -2277,13 +2285,13 @@ class VideoShotImage extends StatefulWidget { Future _getImg(String url) async { final cacheManager = DefaultCacheManager(); - final cacheKey = url.hashCode.toString(); + final cacheKey = Utils.getFileName(url, fileExt: false); final fileInfo = await cacheManager.getFileFromCache(cacheKey); if (fileInfo != null) { final bytes = await fileInfo.file.readAsBytes(); return _loadImg(bytes); } else { - final res = await Request().get( + final res = await Request().get( url, options: Options(responseType: ResponseType.bytes), ); diff --git a/lib/utils/accounts.dart b/lib/utils/accounts.dart index 05dde8bdd..8a161624c 100644 --- a/lib/utils/accounts.dart +++ b/lib/utils/accounts.dart @@ -5,7 +5,7 @@ import 'package:PiliPlus/utils/accounts/account.dart'; import 'package:PiliPlus/utils/login_utils.dart'; import 'package:hive/hive.dart'; -class Accounts { +abstract class Accounts { static late final Box account; static final Map accountMode = {}; static Account get main => accountMode[AccountType.main]!; diff --git a/lib/utils/app_scheme.dart b/lib/utils/app_scheme.dart index 6179768cc..5a90bf408 100644 --- a/lib/utils/app_scheme.dart +++ b/lib/utils/app_scheme.dart @@ -19,7 +19,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -class PiliScheme { +abstract class PiliScheme { static late AppLinks appLinks; static StreamSubscription? listener; static final uriDigitRegExp = RegExp(r'/(\d+)'); diff --git a/lib/utils/app_sign.dart b/lib/utils/app_sign.dart index b2bf2eaa5..95f349b0b 100644 --- a/lib/utils/app_sign.dart +++ b/lib/utils/app_sign.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:PiliPlus/common/constants.dart'; import 'package:crypto/crypto.dart'; -class AppSign { +abstract class AppSign { static void appSign( Map params, [ String appkey = Constants.appKey, diff --git a/lib/utils/context_ext.dart b/lib/utils/context_ext.dart index 60530a81f..8df7368b8 100644 --- a/lib/utils/context_ext.dart +++ b/lib/utils/context_ext.dart @@ -8,24 +8,24 @@ extension ContextExtensions on BuildContext { /// The same of [MediaQuery.of(context).size.height] /// Note: updates when you rezise your screen (like on a browser or /// desktop window) - double get height => mediaQuerySize.height; + double get height => MediaQuery.heightOf(this); /// The same of [MediaQuery.of(context).size.width] /// Note: updates when you rezise your screen (like on a browser or /// desktop window) - double get width => mediaQuerySize.width; + double get width => MediaQuery.widthOf(this); /// similar to [MediaQuery.of(context).padding] ThemeData get theme => Theme.of(this); /// Check if dark mode theme is enable - bool get isDarkMode => (theme.brightness == Brightness.dark); + bool get isDarkMode => (Theme.brightnessOf(this) == Brightness.dark); /// give access to Theme.of(context).iconTheme.color - Color? get iconColor => theme.iconTheme.color; + Color? get iconColor => IconTheme.of(this).color; /// similar to [MediaQuery.of(context).padding] - TextTheme get textTheme => Theme.of(this).textTheme; + TextTheme get textTheme => TextTheme.of(this); /// similar to [MediaQuery.of(context).padding] EdgeInsets get mediaQueryPadding => MediaQuery.viewPaddingOf(this); diff --git a/lib/utils/danmaku_utils.dart b/lib/utils/danmaku_utils.dart index 367e2fc31..f3ce1cf5c 100644 --- a/lib/utils/danmaku_utils.dart +++ b/lib/utils/danmaku_utils.dart @@ -1,14 +1,14 @@ import 'package:canvas_danmaku/models/danmaku_content_item.dart'; import 'package:flutter/material.dart'; -class DmUtils { +abstract class DmUtils { static Color decimalToColor(int decimalColor) { // 16777215 表示白色 int red = (decimalColor >> 16) & 0xFF; int green = (decimalColor >> 8) & 0xFF; int blue = decimalColor & 0xFF; - return Color.fromARGB(255, red, green, blue); + return Color.fromRGBO(red, green, blue, 1); } static DanmakuItemType getPosition(int mode) { diff --git a/lib/utils/date_utils.dart b/lib/utils/date_utils.dart index f24556949..37539dcbd 100644 --- a/lib/utils/date_utils.dart +++ b/lib/utils/date_utils.dart @@ -1,6 +1,6 @@ import 'package:intl/intl.dart' show DateFormat; -class DateFormatUtils { +abstract class DateFormatUtils { static final shortFormat = DateFormat('MM-dd'); static final longFormat = DateFormat('yyyy-MM-dd'); static final _shortFormatD = DateFormat('MM-dd HH:mm'); diff --git a/lib/utils/duration_utils.dart b/lib/utils/duration_utils.dart index cdcdcbfc4..5f0354b4a 100644 --- a/lib/utils/duration_utils.dart +++ b/lib/utils/duration_utils.dart @@ -1,6 +1,6 @@ import 'dart:math' show pow; -class DurationUtils { +abstract class DurationUtils { static String formatDuration(num? seconds) { if (seconds == null || seconds == 0) { return '00:00'; diff --git a/lib/utils/em.dart b/lib/utils/em.dart index b68c3e1bd..352dd26b2 100644 --- a/lib/utils/em.dart +++ b/lib/utils/em.dart @@ -1,6 +1,6 @@ import 'package:html/parser.dart' show parse; -class Em { +abstract class Em { static final _exp = RegExp('<[^>]*>([^<]*)]*>'); static String regCate(String origin) { diff --git a/lib/utils/extension.dart b/lib/utils/extension.dart index 79898c974..0b56cfb7b 100644 --- a/lib/utils/extension.dart +++ b/lib/utils/extension.dart @@ -11,7 +11,7 @@ import 'package:PiliPlus/utils/app_scheme.dart'; import 'package:floating/floating.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; -import 'package:get/get.dart'; +import 'package:get/get.dart' hide ContextExtensionss; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; extension ImageExtension on num? { @@ -29,14 +29,19 @@ extension IntExt on int? { } extension ScrollControllerExt on ScrollController { - void animToTop() { + void animToTop() => animTo(0); + + void animTo( + double offset, { + Duration duration = const Duration(milliseconds: 500), + }) { if (!hasClients) return; - if (offset >= Get.mediaQuery.size.height * 7) { - jumpTo(0); + if ((offset - this.offset).abs() >= position.viewportDimension * 7) { + jumpTo(offset); } else { animateTo( - 0, - duration: const Duration(milliseconds: 500), + offset, + duration: duration, curve: Curves.easeInOut, ); } @@ -227,9 +232,9 @@ extension ThreeDotItemTypeExt on ThreeDotItemType { } extension FileExt on File { - void tryDel({bool recursive = false}) { + Future tryDel({bool recursive = false}) async { try { - delete(recursive: recursive); + await delete(recursive: recursive); } catch (_) {} } } diff --git a/lib/utils/fav_utils.dart b/lib/utils/fav_utils.dart index de09c6dac..238c778d5 100644 --- a/lib/utils/fav_utils.dart +++ b/lib/utils/fav_utils.dart @@ -1,4 +1,4 @@ -class FavUtils { +abstract class FavUtils { static bool isDefaultFav(int? attr) { if (attr == null) { return false; diff --git a/lib/utils/grid.dart b/lib/utils/grid.dart index 904bef241..29f083680 100644 --- a/lib/utils/grid.dart +++ b/lib/utils/grid.dart @@ -16,7 +16,7 @@ mixin GridMixin on State { ); } -class Grid { +abstract class Grid { static final double smallCardWidth = Pref.smallCardWidth; static SliverGridDelegateWithExtentAndRatio videoCardHDelegate( diff --git a/lib/utils/id_utils.dart b/lib/utils/id_utils.dart index 4518b36dc..490dfa00e 100644 --- a/lib/utils/id_utils.dart +++ b/lib/utils/id_utils.dart @@ -5,7 +5,7 @@ import 'dart:convert'; import 'package:PiliPlus/utils/utils.dart'; import 'package:uuid/v4.dart'; -class IdUtils { +abstract class IdUtils { static const XOR_CODE = 23442827791579; static const MASK_CODE = 2251799813685247; static const MAX_AID = 1 << 51; diff --git a/lib/utils/image_utils.dart b/lib/utils/image_utils.dart index 3d1e39f0b..e5dd82e01 100644 --- a/lib/utils/image_utils.dart +++ b/lib/utils/image_utils.dart @@ -17,7 +17,7 @@ import 'package:live_photo_maker/live_photo_maker.dart'; import 'package:saver_gallery/saver_gallery.dart'; import 'package:share_plus/share_plus.dart'; -class ImageUtils { +abstract class ImageUtils { static String get time => DateFormat('yyyy-MM-dd_HH-mm-ss').format(DateTime.now()); static bool silentDownImg = Pref.silentDownImg; diff --git a/lib/utils/login_utils.dart b/lib/utils/login_utils.dart index 2bcb6551c..a57d06344 100644 --- a/lib/utils/login_utils.dart +++ b/lib/utils/login_utils.dart @@ -25,7 +25,7 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart' as web; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -class LoginUtils { +abstract class LoginUtils { static final random = Random(); static FutureOr setWebCookie([Account? account]) { diff --git a/lib/utils/num_utils.dart b/lib/utils/num_utils.dart index a05a705df..fd1e1983a 100644 --- a/lib/utils/num_utils.dart +++ b/lib/utils/num_utils.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart' show kDebugMode, debugPrint; import 'package:get/get_utils/get_utils.dart'; -class NumUtils { +abstract class NumUtils { static final _numRegExp = RegExp(r'([\d\.]+)([千万亿])?'); static int _getUnit(String? unit) { diff --git a/lib/utils/page_utils.dart b/lib/utils/page_utils.dart index 544ddaaa5..d990a6aea 100644 --- a/lib/utils/page_utils.dart +++ b/lib/utils/page_utils.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/hero_dialog_route.dart'; import 'package:PiliPlus/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart'; +import 'package:PiliPlus/common/widgets/marquee.dart'; import 'package:PiliPlus/grpc/im.dart'; import 'package:PiliPlus/http/dynamics.dart'; import 'package:PiliPlus/http/search.dart'; @@ -34,7 +35,7 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart' hide ContextExtensionss; import 'package:url_launcher/url_launcher.dart'; -class PageUtils { +abstract class PageUtils { static final RouteObserver routeObserver = RouteObserver(); @@ -44,7 +45,7 @@ class PageUtils { ValueChanged? onDismissed, int? quality, }) { - return Navigator.of(Get.context!).push( + return Get.key.currentState!.push( HeroDialogRoute( builder: (context) => InteractiveviewerGallery( sources: imgList, @@ -558,23 +559,23 @@ class PageUtils { } } - static void onHorizontalPreview( - GlobalKey key, + static void onHorizontalPreviewState( + ScaffoldState state, TickerProvider vsync, - List imgList, + List imgList, int index, ) { final ctr = AnimationController( vsync: vsync, duration: const Duration(milliseconds: 200), )..forward(); - key.currentState?.showBottomSheet( + state.showBottomSheet( constraints: const BoxConstraints(), (context) { return FadeTransition( opacity: Tween(begin: 0, end: 1).animate(ctr), child: InteractiveviewerGallery( - sources: imgList.map((url) => SourceModel(url: url)).toList(), + sources: imgList, initIndex: index, onClose: (value) async { if (!value) { @@ -594,12 +595,30 @@ class PageUtils { ); }, enableDrag: false, - elevation: 0, + elevation: 0.0, backgroundColor: Colors.transparent, sheetAnimationStyle: const AnimationStyle(duration: Duration.zero), ); } + static void onHorizontalPreview( + BuildContext context, + List imgList, + int index, + ) { + final scaffoldState = Scaffold.maybeOf(context); + if (scaffoldState != null) { + onHorizontalPreviewState( + scaffoldState, + ContextSingleTicker(scaffoldState.context), + imgList, + index, + ); + } else { + imageView(imgList: imgList, initialPage: index); + } + } + static void inAppWebview( String url, { bool off = false, diff --git a/lib/utils/request_utils.dart b/lib/utils/request_utils.dart index 020b5eaf9..7c5b97d08 100644 --- a/lib/utils/request_utils.dart +++ b/lib/utils/request_utils.dart @@ -35,7 +35,7 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart' hide ContextExtensionss; import 'package:gt3_flutter_plugin/gt3_flutter_plugin.dart'; -class RequestUtils { +abstract class RequestUtils { static Future syncHistoryStatus() async { final account = Accounts.history; if (!account.isLogin) { diff --git a/lib/utils/storage_key.dart b/lib/utils/storage_key.dart index f6de978d3..f06258a20 100644 --- a/lib/utils/storage_key.dart +++ b/lib/utils/storage_key.dart @@ -1,6 +1,6 @@ // ignore_for_file: constant_identifier_names -class SettingBoxKey { +abstract class SettingBoxKey { static const String btmProgressBehavior = 'btmProgressBehavior', defaultVideoSpeed = 'defaultVideoSpeed', autoUpgradeEnable = 'autoUpgradeEnable', @@ -210,7 +210,7 @@ class SettingBoxKey { reduceLuxColor = 'reduceLuxColor'; } -class LocalCacheKey { +abstract class LocalCacheKey { static const String historyPause = 'historyPause', blackMids = 'blackMids', danmakuFilterRules = 'danmakuFilterRules', @@ -219,7 +219,7 @@ class LocalCacheKey { buvid = 'buvid'; } -class VideoBoxKey { +abstract class VideoBoxKey { static const String videoFit = 'videoFit', videoBrightness = 'videoBrightness', videoSpeed = 'videoSpeed', diff --git a/lib/utils/theme_utils.dart b/lib/utils/theme_utils.dart index d85e1336f..eb2db6b2b 100644 --- a/lib/utils/theme_utils.dart +++ b/lib/utils/theme_utils.dart @@ -5,7 +5,7 @@ import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:flex_seed_scheme/flex_seed_scheme.dart'; import 'package:flutter/material.dart'; -class ThemeUtils { +abstract class ThemeUtils { static ThemeData getThemeData({ required ColorScheme colorScheme, required bool isDynamic, diff --git a/lib/utils/update.dart b/lib/utils/update.dart index 3908d131a..b77268f2d 100644 --- a/lib/utils/update.dart +++ b/lib/utils/update.dart @@ -15,7 +15,7 @@ import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; -class Update { +abstract class Update { // 检查更新 static Future checkUpdate([bool isAuto = true]) async { if (kDebugMode) return; diff --git a/lib/utils/url_utils.dart b/lib/utils/url_utils.dart index 83ffff417..7573ad8ae 100644 --- a/lib/utils/url_utils.dart +++ b/lib/utils/url_utils.dart @@ -8,7 +8,7 @@ import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; -class UrlUtils { +abstract class UrlUtils { // 302重定向路由截取 static Future parseRedirectUrl( String url, [ diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index a426af899..bcbddee2d 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -13,13 +13,15 @@ import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; -class Utils { +abstract class Utils { static final Random random = Random(); static const channel = MethodChannel(Constants.appName); + @pragma("vm:platform-const") static final bool isMobile = Platform.isAndroid || Platform.isIOS; + @pragma("vm:platform-const") static final bool isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux; diff --git a/lib/utils/video_utils.dart b/lib/utils/video_utils.dart index 2b440a807..c02fc76cd 100644 --- a/lib/utils/video_utils.dart +++ b/lib/utils/video_utils.dart @@ -4,7 +4,7 @@ import 'package:PiliPlus/models_new/live/live_room_play_info/codec.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; -class VideoUtils { +abstract class VideoUtils { static String cdnService = Pref.defaultCDNService; static bool disableAudioCDN = Pref.disableAudioCDN; diff --git a/lib/utils/waterfall.dart b/lib/utils/waterfall.dart index 66e7d76e4..d24ab2abf 100644 --- a/lib/utils/waterfall.dart +++ b/lib/utils/waterfall.dart @@ -56,7 +56,8 @@ mixin DynMixin { itemCount: 10, ); } - return SliverList.builder( + return SliverPrototypeExtentList.builder( + prototypeItem: const DynamicCardSkeleton(), itemBuilder: (_, _) => const DynamicCardSkeleton(), itemCount: 10, ); diff --git a/lib/utils/wbi_sign.dart b/lib/utils/wbi_sign.dart index 89010d8eb..8ab53b7ec 100644 --- a/lib/utils/wbi_sign.dart +++ b/lib/utils/wbi_sign.dart @@ -13,7 +13,7 @@ import 'package:crypto/crypto.dart'; import 'package:hive/hive.dart'; import 'package:synchronized/synchronized.dart'; -class WbiSign { +abstract class WbiSign { static Box localCache = GStorage.localCache; static final Lock lock = Lock(); static final RegExp chrFilter = RegExp(r"[!\'\(\)\*]"); diff --git a/pubspec.lock b/pubspec.lock index 3083941fd..620525071 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -505,7 +505,7 @@ packages: description: path: "." ref: mod - resolved-ref: "8289673adc2b0485ab2abda49bea42ef8b899bf0" + resolved-ref: "07dbeaa6f4352cd605e2292f073eca3538635ef3" url: "https://github.com/bggRGjQaUbCoE/extended_nested_scroll_view.git" source: git version: "6.2.1" @@ -1535,14 +1535,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" - scrollable_positioned_list: - dependency: "direct main" - description: - name: scrollable_positioned_list - sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" - url: "https://pub.dev" - source: hosted - version: "0.3.8" sentry: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ee920f82e..7c9c53e5f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -167,7 +167,7 @@ dependencies: # 极验 gt3_flutter_plugin: ^0.1.0 uuid: ^4.5.1 - scrollable_positioned_list: ^0.3.8 + # scrollable_positioned_list: ^0.3.8 # nil: ^1.1.1 catcher_2: ^2.1.0 logger: ^2.5.0