diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 3dd146d3f..a05863692 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -42,7 +42,7 @@ UIInterfaceOrientationLandscapeRight UIViewControllerBasedStatusBarAppearance - + CADisableMinimumFrameDurationOnPhone UIApplicationSupportsIndirectInputEvents diff --git a/lib/common/widgets/article_content.dart b/lib/common/widgets/article_content.dart index b3671e152..85678454d 100644 --- a/lib/common/widgets/article_content.dart +++ b/lib/common/widgets/article_content.dart @@ -16,7 +16,7 @@ Widget articleContent({ .toList(); return SliverList.separated( itemCount: list.length, - itemBuilder: (_, index) { + itemBuilder: (context, index) { ArticleContentModel item = list[index]; if (item.text != null) { List spanList = []; @@ -55,7 +55,7 @@ Widget articleContent({ ); } else if (item.pic != null) { return LayoutBuilder( - builder: (_, constraints) => GestureDetector( + builder: (context, constraints) => GestureDetector( onTap: () { context.imageView( initialPage: imgList.indexOf(item.pic!.pics!.first.url!), diff --git a/lib/common/widgets/imageview.dart b/lib/common/widgets/imageview.dart index 54a2641a6..88e08f68d 100644 --- a/lib/common/widgets/imageview.dart +++ b/lib/common/widgets/imageview.dart @@ -23,10 +23,12 @@ class ImageModel { bool get isLongPic => _isLongPic ??= (safeHeight / safeWidth) > (22 / 9); } -Widget image( +Widget imageview( double maxWidth, - List picArr, -) { + List picArr, [ + VoidCallback? onViewImage, + ValueChanged? onDismissed, +]) { double imageWidth = (maxWidth - 2 * 5) / 3; double imageHeight = imageWidth; if (picArr.length == 1) { @@ -53,43 +55,48 @@ Widget image( height: picArr.length == 1 ? imageHeight : null, width: picArr.length == 1 ? imageWidth : maxWidth, itemCount: picArr.length, - itemBuilder: (context, index) => GestureDetector( - onTap: () { - context.imageView( - initialPage: index, - imgList: picArr.map((item) => item.url).toList(), - ); - // showDialog( - // useSafeArea: false, - // context: context, - // builder: (context) { - // return ImagePreview( - // initialPage: index, - // imgList: picArr.map((item) => item.url).toList(), - // ); - // }, - // ); - }, - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: NetworkImgLayer( - src: picArr[index].url, - width: imageWidth, - height: imageHeight, - isLongPic: () => picArr[index].isLongPic, - callback: () => - picArr[index].safeWidth <= picArr[index].safeHeight, + itemBuilder: (context, index) => Hero( + tag: picArr[index].url, + child: GestureDetector( + onTap: () { + onViewImage?.call(); + context.imageView( + initialPage: index, + imgList: picArr.map((item) => item.url).toList(), + onDismissed: onDismissed, + ); + // showDialog( + // useSafeArea: false, + // context: context, + // builder: (context) { + // return ImagePreview( + // initialPage: index, + // imgList: picArr.map((item) => item.url).toList(), + // ); + // }, + // ); + }, + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: NetworkImgLayer( + src: picArr[index].url, + width: imageWidth, + height: imageHeight, + isLongPic: () => picArr[index].isLongPic, + callback: () => + picArr[index].safeWidth <= picArr[index].safeHeight, + ), ), - ), - if (picArr[index].isLongPic) - const PBadge( - text: '长图', - right: 8, - bottom: 8, - ), - ], + if (picArr[index].isLongPic) + const PBadge( + text: '长图', + right: 8, + bottom: 8, + ), + ], + ), ), ), ); diff --git a/lib/common/widgets/interactiveviewer_gallery/hero_dialog_route.dart b/lib/common/widgets/interactiveviewer_gallery/hero_dialog_route.dart new file mode 100644 index 000000000..238bc3bfe --- /dev/null +++ b/lib/common/widgets/interactiveviewer_gallery/hero_dialog_route.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +/// https://github.com/qq326646683/interactiveviewer_gallery + +/// A [PageRoute] with a semi transparent background. +/// +/// Similar to calling [showDialog] except it can be used with a [Navigator] to +/// show a [Hero] animation. +class HeroDialogRoute extends PageRoute { + HeroDialogRoute({ + required this.builder, + }); + + final WidgetBuilder builder; + + @override + bool get opaque => false; + + @override + bool get barrierDismissible => true; + + @override + String? get barrierLabel => null; + + @override + Duration get transitionDuration => const Duration(milliseconds: 300); + + @override + bool get maintainState => true; + + @override + Color? get barrierColor => null; + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), + child: child, + ); + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + final Widget child = builder(context); + final Widget result = Semantics( + scopesRoute: true, + explicitChildNodes: true, + child: child, + ); + return result; + } +} diff --git a/lib/common/widgets/interactiveviewer_gallery/interactive_viewer.dart b/lib/common/widgets/interactiveviewer_gallery/interactive_viewer.dart new file mode 100644 index 000000000..876223034 --- /dev/null +++ b/lib/common/widgets/interactiveviewer_gallery/interactive_viewer.dart @@ -0,0 +1,1503 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show clampDouble; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:vector_math/vector_math_64.dart' show Matrix4, Quad, Vector3; + +// Examples can assume: +// late BuildContext context; +// late Offset? _childWasTappedAt; +// late TransformationController _transformationController; +// Widget child = const Placeholder(); + +/// A signature for widget builders that take a [Quad] of the current viewport. +/// +/// See also: +/// +/// * [InteractiveViewer.builder], whose builder is of this type. +/// * [WidgetBuilder], which is similar, but takes no viewport. +typedef InteractiveViewerWidgetBuilder = Widget Function( + BuildContext context, Quad viewport); + +/// A widget that enables pan and zoom interactions with its child. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=zrn7V3bMJvg} +/// +/// The user can transform the child by dragging to pan or pinching to zoom. +/// +/// By default, InteractiveViewer clips its child using [Clip.hardEdge]. +/// To prevent this behavior, consider setting [clipBehavior] to [Clip.none]. +/// When [clipBehavior] is [Clip.none], InteractiveViewer may draw outside of +/// its original area of the screen, such as when a child is zoomed in and +/// increases in size. However, it will not receive gestures outside of its original area. +/// To prevent dead areas where InteractiveViewer does not receive gestures, +/// don't set [clipBehavior] or be sure that the InteractiveViewer widget is the +/// size of the area that should be interactive. +/// +/// See also: +/// * The [Flutter Gallery's transformations demo](https://github.com/flutter/gallery/blob/main/lib/demos/reference/transformations_demo.dart), +/// which includes the use of InteractiveViewer. +/// * The [flutter-go demo](https://github.com/justinmc/flutter-go), which includes robust positioning of an InteractiveViewer child +/// that works for all screen sizes and child sizes. +/// * The [Lazy Flutter Performance Session](https://www.youtube.com/watch?v=qax_nOpgz7E), which includes the use of an InteractiveViewer to +/// performantly view subsets of a large set of widgets using the builder constructor. +/// +/// {@tool dartpad} +/// This example shows a simple Container that can be panned and zoomed. +/// +/// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.0.dart ** +/// {@end-tool} +@immutable +class InteractiveViewer extends StatefulWidget { + /// Create an InteractiveViewer. + InteractiveViewer({ + super.key, + this.clipBehavior = Clip.hardEdge, + this.panAxis = PanAxis.free, + this.boundaryMargin = EdgeInsets.zero, + this.constrained = true, + // These default scale values were eyeballed as reasonable limits for common + // use cases. + this.maxScale = 2.5, + this.minScale = 0.8, + this.interactionEndFrictionCoefficient = _kDrag, + this.onInteractionEnd, + this.onInteractionStart, + this.onInteractionUpdate, + this.panEnabled = true, + this.scaleEnabled = true, + this.scaleFactor = kDefaultMouseScrollToScaleFactor, + this.transformationController, + this.alignment, + this.trackpadScrollCausesScale = false, + this.onPanStart, + this.onPanUpdate, + this.onPanEnd, + required Widget this.child, + }) : assert(minScale > 0), + assert(interactionEndFrictionCoefficient > 0), + assert(minScale.isFinite), + assert(maxScale > 0), + assert(!maxScale.isNaN), + assert(maxScale >= minScale), + // boundaryMargin must be either fully infinite or fully finite, but not + // a mix of both. + assert( + (boundaryMargin.horizontal.isInfinite && + boundaryMargin.vertical.isInfinite) || + (boundaryMargin.top.isFinite && + boundaryMargin.right.isFinite && + boundaryMargin.bottom.isFinite && + boundaryMargin.left.isFinite), + ), + builder = null; + + /// Creates an InteractiveViewer for a child that is created on demand. + /// + /// Can be used to render a child that changes in response to the current + /// transformation. + /// + /// See the [builder] attribute docs for an example of using it to optimize a + /// large child. + InteractiveViewer.builder({ + super.key, + this.clipBehavior = Clip.hardEdge, + this.panAxis = PanAxis.free, + this.boundaryMargin = EdgeInsets.zero, + // These default scale values were eyeballed as reasonable limits for common + // use cases. + this.maxScale = 2.5, + this.minScale = 0.8, + this.interactionEndFrictionCoefficient = _kDrag, + this.onInteractionEnd, + this.onInteractionStart, + this.onInteractionUpdate, + this.panEnabled = true, + this.scaleEnabled = true, + this.scaleFactor = 200.0, + this.transformationController, + this.alignment, + this.trackpadScrollCausesScale = false, + this.onPanStart, + this.onPanUpdate, + this.onPanEnd, + required InteractiveViewerWidgetBuilder this.builder, + }) : assert(minScale > 0), + assert(interactionEndFrictionCoefficient > 0), + assert(minScale.isFinite), + assert(maxScale > 0), + assert(!maxScale.isNaN), + assert(maxScale >= minScale), + // boundaryMargin must be either fully infinite or fully finite, but not + // a mix of both. + assert( + (boundaryMargin.horizontal.isInfinite && + boundaryMargin.vertical.isInfinite) || + (boundaryMargin.top.isFinite && + boundaryMargin.right.isFinite && + boundaryMargin.bottom.isFinite && + boundaryMargin.left.isFinite), + ), + constrained = false, + child = null; + + final ValueChanged? onPanStart; + final ValueChanged? onPanUpdate; + final ValueChanged? onPanEnd; + + /// The alignment of the child's origin, relative to the size of the box. + final Alignment? alignment; + + /// If set to [Clip.none], the child may extend beyond the size of the InteractiveViewer, + /// but it will not receive gestures in these areas. + /// Be sure that the InteractiveViewer is the desired size when using [Clip.none]. + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + /// When set to [PanAxis.aligned], panning is only allowed in the horizontal + /// axis or the vertical axis, diagonal panning is not allowed. + /// + /// When set to [PanAxis.vertical] or [PanAxis.horizontal] panning is only + /// allowed in the specified axis. For example, if set to [PanAxis.vertical], + /// panning will only be allowed in the vertical axis. And if set to [PanAxis.horizontal], + /// panning will only be allowed in the horizontal axis. + /// + /// When set to [PanAxis.free] panning is allowed in all directions. + /// + /// Defaults to [PanAxis.free]. + final PanAxis panAxis; + + /// A margin for the visible boundaries of the child. + /// + /// Any transformation that results in the viewport being able to view outside + /// of the boundaries will be stopped at the boundary. The boundaries do not + /// rotate with the rest of the scene, so they are always aligned with the + /// viewport. + /// + /// To produce no boundaries at all, pass infinite [EdgeInsets], such as + /// `EdgeInsets.all(double.infinity)`. + /// + /// No edge can be NaN. + /// + /// Defaults to [EdgeInsets.zero], which results in boundaries that are the + /// exact same size and position as the [child]. + final EdgeInsets boundaryMargin; + + /// Builds the child of this widget. + /// + /// Passed with the [InteractiveViewer.builder] constructor. Otherwise, the + /// [child] parameter must be passed directly, and this is null. + /// + /// {@tool dartpad} + /// This example shows how to use builder to create a [Table] whose cell + /// contents are only built when they are visible. Built and remove cells are + /// logged in the console for illustration. + /// + /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.builder.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [ListView.builder], which follows a similar pattern. + final InteractiveViewerWidgetBuilder? builder; + + /// The child [Widget] that is transformed by InteractiveViewer. + /// + /// If the [InteractiveViewer.builder] constructor is used, then this will be + /// null, otherwise it is required. + final Widget? child; + + /// Whether the normal size constraints at this point in the widget tree are + /// applied to the child. + /// + /// If set to false, then the child will be given infinite constraints. This + /// is often useful when a child should be bigger than the InteractiveViewer. + /// + /// For example, for a child which is bigger than the viewport but can be + /// panned to reveal parts that were initially offscreen, [constrained] must + /// be set to false to allow it to size itself properly. If [constrained] is + /// true and the child can only size itself to the viewport, then areas + /// initially outside of the viewport will not be able to receive user + /// interaction events. If experiencing regions of the child that are not + /// receptive to user gestures, make sure [constrained] is false and the child + /// is sized properly. + /// + /// Defaults to true. + /// + /// {@tool dartpad} + /// This example shows how to create a pannable table. Because the table is + /// larger than the entire screen, setting [constrained] to false is necessary + /// to allow it to be drawn to its full size. The parts of the table that + /// exceed the screen size can then be panned into view. + /// + /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.constrained.0.dart ** + /// {@end-tool} + final bool constrained; + + /// If false, the user will be prevented from panning. + /// + /// Defaults to true. + /// + /// See also: + /// + /// * [scaleEnabled], which is similar but for scale. + final bool panEnabled; + + /// If false, the user will be prevented from scaling. + /// + /// Defaults to true. + /// + /// See also: + /// + /// * [panEnabled], which is similar but for panning. + final bool scaleEnabled; + + /// {@macro flutter.gestures.scale.trackpadScrollCausesScale} + final bool trackpadScrollCausesScale; + + /// Determines the amount of scale to be performed per pointer scroll. + /// + /// Defaults to [kDefaultMouseScrollToScaleFactor]. + /// + /// Increasing this value above the default causes scaling to feel slower, + /// while decreasing it causes scaling to feel faster. + /// + /// The amount of scale is calculated as the exponential function of the + /// [PointerScrollEvent.scrollDelta] to [scaleFactor] ratio. In the Flutter + /// engine, the mousewheel [PointerScrollEvent.scrollDelta] is hardcoded to 20 + /// per scroll, while a trackpad scroll can be any amount. + /// + /// Affects only pointer device scrolling, not pinch to zoom. + final double scaleFactor; + + /// The maximum allowed scale. + /// + /// The scale will be clamped between this and [minScale] inclusively. + /// + /// Defaults to 2.5. + /// + /// Must be greater than zero and greater than [minScale]. + final double maxScale; + + /// The minimum allowed scale. + /// + /// The scale will be clamped between this and [maxScale] inclusively. + /// + /// Scale is also affected by [boundaryMargin]. If the scale would result in + /// viewing beyond the boundary, then it will not be allowed. By default, + /// boundaryMargin is EdgeInsets.zero, so scaling below 1.0 will not be + /// allowed in most cases without first increasing the boundaryMargin. + /// + /// Defaults to 0.8. + /// + /// Must be a finite number greater than zero and less than [maxScale]. + final double minScale; + + /// Changes the deceleration behavior after a gesture. + /// + /// Defaults to 0.0000135. + /// + /// Must be a finite number greater than zero. + final double interactionEndFrictionCoefficient; + + /// Called when the user ends a pan or scale gesture on the widget. + /// + /// At the time this is called, the [TransformationController] will have + /// already been updated to reflect the change caused by the interaction, + /// though a pan may cause an inertia animation after this is called as well. + /// + /// {@template flutter.widgets.InteractiveViewer.onInteractionEnd} + /// Will be called even if the interaction is disabled with [panEnabled] or + /// [scaleEnabled] for both touch gestures and mouse interactions. + /// + /// A [GestureDetector] wrapping the InteractiveViewer will not respond to + /// [GestureDetector.onScaleStart], [GestureDetector.onScaleUpdate], and + /// [GestureDetector.onScaleEnd]. Use [onInteractionStart], + /// [onInteractionUpdate], and [onInteractionEnd] to respond to those + /// gestures. + /// {@endtemplate} + /// + /// See also: + /// + /// * [onInteractionStart], which handles the start of the same interaction. + /// * [onInteractionUpdate], which handles an update to the same interaction. + final GestureScaleEndCallback? onInteractionEnd; + + /// Called when the user begins a pan or scale gesture on the widget. + /// + /// At the time this is called, the [TransformationController] will not have + /// changed due to this interaction. + /// + /// {@macro flutter.widgets.InteractiveViewer.onInteractionEnd} + /// + /// The coordinates provided in the details' `focalPoint` and + /// `localFocalPoint` are normal Flutter event coordinates, not + /// InteractiveViewer scene coordinates. See + /// [TransformationController.toScene] for how to convert these coordinates to + /// scene coordinates relative to the child. + /// + /// See also: + /// + /// * [onInteractionUpdate], which handles an update to the same interaction. + /// * [onInteractionEnd], which handles the end of the same interaction. + final GestureScaleStartCallback? onInteractionStart; + + /// Called when the user updates a pan or scale gesture on the widget. + /// + /// At the time this is called, the [TransformationController] will have + /// already been updated to reflect the change caused by the interaction, if + /// the interaction caused the matrix to change. + /// + /// {@macro flutter.widgets.InteractiveViewer.onInteractionEnd} + /// + /// The coordinates provided in the details' `focalPoint` and + /// `localFocalPoint` are normal Flutter event coordinates, not + /// InteractiveViewer scene coordinates. See + /// [TransformationController.toScene] for how to convert these coordinates to + /// scene coordinates relative to the child. + /// + /// See also: + /// + /// * [onInteractionStart], which handles the start of the same interaction. + /// * [onInteractionEnd], which handles the end of the same interaction. + final GestureScaleUpdateCallback? onInteractionUpdate; + + /// A [TransformationController] for the transformation performed on the + /// child. + /// + /// Whenever the child is transformed, the [Matrix4] value is updated and all + /// listeners are notified. If the value is set, InteractiveViewer will update + /// to respect the new value. + /// + /// {@tool dartpad} + /// This example shows how transformationController can be used to animate the + /// transformation back to its starting position. + /// + /// ** See code in examples/api/lib/widgets/interactive_viewer/interactive_viewer.transformation_controller.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [ValueNotifier], the parent class of TransformationController. + /// * [TextEditingController] for an example of another similar pattern. + final TransformationController? transformationController; + + // Used as the coefficient of friction in the inertial translation animation. + // This value was eyeballed to give a feel similar to Google Photos. + static const double _kDrag = 0.0000135; + + /// Returns the closest point to the given point on the given line segment. + @visibleForTesting + static Vector3 getNearestPointOnLine(Vector3 point, Vector3 l1, Vector3 l2) { + final double lengthSquared = math.pow(l2.x - l1.x, 2.0).toDouble() + + math.pow(l2.y - l1.y, 2.0).toDouble(); + + // In this case, l1 == l2. + if (lengthSquared == 0) { + return l1; + } + + // Calculate how far down the line segment the closest point is and return + // the point. + final Vector3 l1P = point - l1; + final Vector3 l1L2 = l2 - l1; + final double fraction = + clampDouble(l1P.dot(l1L2) / lengthSquared, 0.0, 1.0); + return l1 + l1L2 * fraction; + } + + /// Given a quad, return its axis aligned bounding box. + @visibleForTesting + static Quad getAxisAlignedBoundingBox(Quad quad) { + final double minX = math.min( + quad.point0.x, + math.min( + quad.point1.x, + math.min( + quad.point2.x, + quad.point3.x, + ), + ), + ); + final double minY = math.min( + quad.point0.y, + math.min( + quad.point1.y, + math.min( + quad.point2.y, + quad.point3.y, + ), + ), + ); + final double maxX = math.max( + quad.point0.x, + math.max( + quad.point1.x, + math.max( + quad.point2.x, + quad.point3.x, + ), + ), + ); + final double maxY = math.max( + quad.point0.y, + math.max( + quad.point1.y, + math.max( + quad.point2.y, + quad.point3.y, + ), + ), + ); + return Quad.points( + Vector3(minX, minY, 0), + Vector3(maxX, minY, 0), + Vector3(maxX, maxY, 0), + Vector3(minX, maxY, 0), + ); + } + + /// Returns true iff the point is inside the rectangle given by the Quad, + /// inclusively. + /// Algorithm from https://math.stackexchange.com/a/190373. + @visibleForTesting + static bool pointIsInside(Vector3 point, Quad quad) { + final Vector3 aM = point - quad.point0; + final Vector3 aB = quad.point1 - quad.point0; + final Vector3 aD = quad.point3 - quad.point0; + + final double aMAB = aM.dot(aB); + final double aBAB = aB.dot(aB); + final double aMAD = aM.dot(aD); + final double aDAD = aD.dot(aD); + + return 0 <= aMAB && aMAB <= aBAB && 0 <= aMAD && aMAD <= aDAD; + } + + /// Get the point inside (inclusively) the given Quad that is nearest to the + /// given Vector3. + @visibleForTesting + static Vector3 getNearestPointInside(Vector3 point, Quad quad) { + // If the point is inside the axis aligned bounding box, then it's ok where + // it is. + if (pointIsInside(point, quad)) { + return point; + } + + // Otherwise, return the nearest point on the quad. + final List closestPoints = [ + InteractiveViewer.getNearestPointOnLine(point, quad.point0, quad.point1), + InteractiveViewer.getNearestPointOnLine(point, quad.point1, quad.point2), + InteractiveViewer.getNearestPointOnLine(point, quad.point2, quad.point3), + InteractiveViewer.getNearestPointOnLine(point, quad.point3, quad.point0), + ]; + double minDistance = double.infinity; + late Vector3 closestOverall; + for (final Vector3 closePoint in closestPoints) { + final double distance = math.sqrt( + math.pow(point.x - closePoint.x, 2) + + math.pow(point.y - closePoint.y, 2), + ); + if (distance < minDistance) { + minDistance = distance; + closestOverall = closePoint; + } + } + return closestOverall; + } + + @override + State createState() => _InteractiveViewerState(); +} + +class _InteractiveViewerState extends State + with TickerProviderStateMixin { + TransformationController? _transformationController; + + final GlobalKey _childKey = GlobalKey(); + final GlobalKey _parentKey = GlobalKey(); + Animation? _animation; + Animation? _scaleAnimation; + late Offset _scaleAnimationFocalPoint; + late AnimationController _controller; + late AnimationController _scaleController; + Axis? _currentAxis; // Used with panAxis. + Offset? _referenceFocalPoint; // Point where the current gesture began. + double? _scaleStart; // Scale value at start of scaling gesture. + double? _rotationStart = 0.0; // Rotation at start of rotation gesture. + double _currentRotation = 0.0; // Rotation of _transformationController.value. + _GestureType? _gestureType; + + // TODO(justinmc): Add rotateEnabled parameter to the widget and remove this + // hardcoded value when the rotation feature is implemented. + // https://github.com/flutter/flutter/issues/57698 + final bool _rotateEnabled = false; + + // The _boundaryRect is calculated by adding the boundaryMargin to the size of + // the child. + Rect get _boundaryRect { + assert(_childKey.currentContext != null); + assert(!widget.boundaryMargin.left.isNaN); + assert(!widget.boundaryMargin.right.isNaN); + assert(!widget.boundaryMargin.top.isNaN); + assert(!widget.boundaryMargin.bottom.isNaN); + + final RenderBox childRenderBox = + _childKey.currentContext!.findRenderObject()! as RenderBox; + final Size childSize = childRenderBox.size; + final Rect boundaryRect = + widget.boundaryMargin.inflateRect(Offset.zero & childSize); + assert( + !boundaryRect.isEmpty, + "InteractiveViewer's child must have nonzero dimensions.", + ); + // Boundaries that are partially infinite are not allowed because Matrix4's + // rotation and translation methods don't handle infinites well. + assert( + boundaryRect.isFinite || + (boundaryRect.left.isInfinite && + boundaryRect.top.isInfinite && + boundaryRect.right.isInfinite && + boundaryRect.bottom.isInfinite), + 'boundaryRect must either be infinite in all directions or finite in all directions.', + ); + return boundaryRect; + } + + // The Rect representing the child's parent. + Rect get _viewport { + assert(_parentKey.currentContext != null); + final RenderBox parentRenderBox = + _parentKey.currentContext!.findRenderObject()! as RenderBox; + return Offset.zero & parentRenderBox.size; + } + + // Return a new matrix representing the given matrix after applying the given + // translation. + Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) { + if (translation == Offset.zero) { + return matrix.clone(); + } + + final Offset alignedTranslation; + + if (_currentAxis != null) { + alignedTranslation = switch (widget.panAxis) { + PanAxis.horizontal => _alignAxis(translation, Axis.horizontal), + PanAxis.vertical => _alignAxis(translation, Axis.vertical), + PanAxis.aligned => _alignAxis(translation, _currentAxis!), + PanAxis.free => translation, + }; + } else { + alignedTranslation = translation; + } + + final Matrix4 nextMatrix = matrix.clone() + ..translate( + alignedTranslation.dx, + alignedTranslation.dy, + ); + + // Transform the viewport to determine where its four corners will be after + // the child has been transformed. + final Quad nextViewport = _transformViewport(nextMatrix, _viewport); + + // If the boundaries are infinite, then no need to check if the translation + // fits within them. + if (_boundaryRect.isInfinite) { + return nextMatrix; + } + + // Expand the boundaries with rotation. This prevents the problem where a + // mismatch in orientation between the viewport and boundaries effectively + // limits translation. With this approach, all points that are visible with + // no rotation are visible after rotation. + final Quad boundariesAabbQuad = _getAxisAlignedBoundingBoxWithRotation( + _boundaryRect, + _currentRotation, + ); + + // If the given translation fits completely within the boundaries, allow it. + final Offset offendingDistance = + _exceedsBy(boundariesAabbQuad, nextViewport); + if (offendingDistance == Offset.zero) { + return nextMatrix; + } + + // Desired translation goes out of bounds, so translate to the nearest + // in-bounds point instead. + final Offset nextTotalTranslation = _getMatrixTranslation(nextMatrix); + final double currentScale = matrix.getMaxScaleOnAxis(); + final Offset correctedTotalTranslation = Offset( + nextTotalTranslation.dx - offendingDistance.dx * currentScale, + nextTotalTranslation.dy - offendingDistance.dy * currentScale, + ); + // TODO(justinmc): This needs some work to handle rotation properly. The + // idea is that the boundaries are axis aligned (boundariesAabbQuad), but + // calculating the translation to put the viewport inside that Quad is more + // complicated than this when rotated. + // https://github.com/flutter/flutter/issues/57698 + final Matrix4 correctedMatrix = matrix.clone() + ..setTranslation(Vector3( + correctedTotalTranslation.dx, + correctedTotalTranslation.dy, + 0.0, + )); + + // Double check that the corrected translation fits. + final Quad correctedViewport = + _transformViewport(correctedMatrix, _viewport); + final Offset offendingCorrectedDistance = + _exceedsBy(boundariesAabbQuad, correctedViewport); + if (offendingCorrectedDistance == Offset.zero) { + return correctedMatrix; + } + + // If the corrected translation doesn't fit in either direction, don't allow + // any translation at all. This happens when the viewport is larger than the + // entire boundary. + if (offendingCorrectedDistance.dx != 0.0 && + offendingCorrectedDistance.dy != 0.0) { + return matrix.clone(); + } + + // Otherwise, allow translation in only the direction that fits. This + // happens when the viewport is larger than the boundary in one direction. + final Offset unidirectionalCorrectedTotalTranslation = Offset( + offendingCorrectedDistance.dx == 0.0 ? correctedTotalTranslation.dx : 0.0, + offendingCorrectedDistance.dy == 0.0 ? correctedTotalTranslation.dy : 0.0, + ); + return matrix.clone() + ..setTranslation(Vector3( + unidirectionalCorrectedTotalTranslation.dx, + unidirectionalCorrectedTotalTranslation.dy, + 0.0, + )); + } + + // Return a new matrix representing the given matrix after applying the given + // scale. + Matrix4 _matrixScale(Matrix4 matrix, double scale) { + if (scale == 1.0) { + return matrix.clone(); + } + assert(scale != 0.0); + + // Don't allow a scale that results in an overall scale beyond min/max + // scale. + final double currentScale = + _transformationController!.value.getMaxScaleOnAxis(); + final double totalScale = math.max( + currentScale * scale, + // Ensure that the scale cannot make the child so big that it can't fit + // inside the boundaries (in either direction). + math.max( + _viewport.width / _boundaryRect.width, + _viewport.height / _boundaryRect.height, + ), + ); + final double clampedTotalScale = clampDouble( + totalScale, + widget.minScale, + widget.maxScale, + ); + final double clampedScale = clampedTotalScale / currentScale; + return matrix.clone()..scale(clampedScale); + } + + // Return a new matrix representing the given matrix after applying the given + // rotation. + Matrix4 _matrixRotate(Matrix4 matrix, double rotation, Offset focalPoint) { + if (rotation == 0) { + return matrix.clone(); + } + final Offset focalPointScene = _transformationController!.toScene( + focalPoint, + ); + return matrix.clone() + ..translate(focalPointScene.dx, focalPointScene.dy) + ..rotateZ(-rotation) + ..translate(-focalPointScene.dx, -focalPointScene.dy); + } + + // Returns true iff the given _GestureType is enabled. + bool _gestureIsSupported(_GestureType? gestureType) { + return switch (gestureType) { + _GestureType.rotate => _rotateEnabled, + _GestureType.scale => widget.scaleEnabled, + _GestureType.pan || null => widget.panEnabled, + }; + } + + // Decide which type of gesture this is by comparing the amount of scale + // and rotation in the gesture, if any. Scale starts at 1 and rotation + // starts at 0. Pan will have no scale and no rotation because it uses only one + // finger. + _GestureType _getGestureType(ScaleUpdateDetails details) { + final double scale = !widget.scaleEnabled ? 1.0 : details.scale; + final double rotation = !_rotateEnabled ? 0.0 : details.rotation; + if ((scale - 1).abs() > rotation.abs()) { + return _GestureType.scale; + } else if (rotation != 0.0) { + return _GestureType.rotate; + } else { + return _GestureType.pan; + } + } + + // Handle the start of a gesture. All of pan, scale, and rotate are handled + // with GestureDetector's scale gesture. + void _onScaleStart(ScaleStartDetails details) { + if (details.pointerCount < 2 && + _transformationController?.value.row0.x == 1.0) { + widget.onPanStart?.call(details); + return; + } + + widget.onInteractionStart?.call(details); + + if (_controller.isAnimating) { + _controller.stop(); + _controller.reset(); + _animation?.removeListener(_onAnimate); + _animation = null; + } + if (_scaleController.isAnimating) { + _scaleController.stop(); + _scaleController.reset(); + _scaleAnimation?.removeListener(_onScaleAnimate); + _scaleAnimation = null; + } + + _gestureType = null; + _currentAxis = null; + _scaleStart = _transformationController!.value.getMaxScaleOnAxis(); + _referenceFocalPoint = _transformationController!.toScene( + details.localFocalPoint, + ); + _rotationStart = _currentRotation; + } + + // Handle an update to an ongoing gesture. All of pan, scale, and rotate are + // handled with GestureDetector's scale gesture. + void _onScaleUpdate(ScaleUpdateDetails details) { + if (details.pointerCount < 2 && + _transformationController?.value.row0.x == 1.0) { + widget.onPanUpdate?.call(details); + return; + } + + final double scale = _transformationController!.value.getMaxScaleOnAxis(); + _scaleAnimationFocalPoint = details.localFocalPoint; + final Offset focalPointScene = _transformationController!.toScene( + details.localFocalPoint, + ); + + if (_gestureType == _GestureType.pan) { + // When a gesture first starts, it sometimes has no change in scale and + // rotation despite being a two-finger gesture. Here the gesture is + // allowed to be reinterpreted as its correct type after originally + // being marked as a pan. + _gestureType = _getGestureType(details); + } else { + _gestureType ??= _getGestureType(details); + } + if (!_gestureIsSupported(_gestureType)) { + widget.onInteractionUpdate?.call(details); + return; + } + + switch (_gestureType!) { + case _GestureType.scale: + assert(_scaleStart != null); + // details.scale gives us the amount to change the scale as of the + // start of this gesture, so calculate the amount to scale as of the + // previous call to _onScaleUpdate. + final double desiredScale = _scaleStart! * details.scale; + final double scaleChange = desiredScale / scale; + _transformationController!.value = _matrixScale( + _transformationController!.value, + scaleChange, + ); + + // While scaling, translate such that the user's two fingers stay on + // the same places in the scene. That means that the focal point of + // the scale should be on the same place in the scene before and after + // the scale. + final Offset focalPointSceneScaled = _transformationController!.toScene( + details.localFocalPoint, + ); + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + focalPointSceneScaled - _referenceFocalPoint!, + ); + + // details.localFocalPoint should now be at the same location as the + // original _referenceFocalPoint point. If it's not, that's because + // the translate came in contact with a boundary. In that case, update + // _referenceFocalPoint so subsequent updates happen in relation to + // the new effective focal point. + final Offset focalPointSceneCheck = _transformationController!.toScene( + details.localFocalPoint, + ); + if (_round(_referenceFocalPoint!) != _round(focalPointSceneCheck)) { + _referenceFocalPoint = focalPointSceneCheck; + } + + case _GestureType.rotate: + if (details.rotation == 0.0) { + widget.onInteractionUpdate?.call(details); + return; + } + final double desiredRotation = _rotationStart! + details.rotation; + _transformationController!.value = _matrixRotate( + _transformationController!.value, + _currentRotation - desiredRotation, + details.localFocalPoint, + ); + _currentRotation = desiredRotation; + + case _GestureType.pan: + assert(_referenceFocalPoint != null); + // details may have a change in scale here when scaleEnabled is false. + // In an effort to keep the behavior similar whether or not scaleEnabled + // is true, these gestures are thrown away. + if (details.scale != 1.0) { + widget.onInteractionUpdate?.call(details); + return; + } + _currentAxis ??= _getPanAxis(_referenceFocalPoint!, focalPointScene); + // Translate so that the same point in the scene is underneath the + // focal point before and after the movement. + final Offset translationChange = + focalPointScene - _referenceFocalPoint!; + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + translationChange, + ); + _referenceFocalPoint = _transformationController!.toScene( + details.localFocalPoint, + ); + } + widget.onInteractionUpdate?.call(details); + } + + // Handle the end of a gesture of _GestureType. All of pan, scale, and rotate + // are handled with GestureDetector's scale gesture. + void _onScaleEnd(ScaleEndDetails details) { + if (details.pointerCount < 2 && + _transformationController?.value.row0.x == 1.0) { + widget.onPanEnd?.call(details); + return; + } + + widget.onInteractionEnd?.call(details); + _scaleStart = null; + _rotationStart = null; + _referenceFocalPoint = null; + + _animation?.removeListener(_onAnimate); + _scaleAnimation?.removeListener(_onScaleAnimate); + _controller.reset(); + _scaleController.reset(); + + if (!_gestureIsSupported(_gestureType)) { + _currentAxis = null; + return; + } + + switch (_gestureType) { + case _GestureType.pan: + if (details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) { + _currentAxis = null; + return; + } + final Vector3 translationVector = + _transformationController!.value.getTranslation(); + final Offset translation = + Offset(translationVector.x, translationVector.y); + final FrictionSimulation frictionSimulationX = FrictionSimulation( + widget.interactionEndFrictionCoefficient, + translation.dx, + details.velocity.pixelsPerSecond.dx, + ); + final FrictionSimulation frictionSimulationY = FrictionSimulation( + widget.interactionEndFrictionCoefficient, + translation.dy, + details.velocity.pixelsPerSecond.dy, + ); + final double tFinal = _getFinalTime( + details.velocity.pixelsPerSecond.distance, + widget.interactionEndFrictionCoefficient, + ); + _animation = Tween( + begin: translation, + end: Offset(frictionSimulationX.finalX, frictionSimulationY.finalX), + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.decelerate, + )); + _controller.duration = Duration(milliseconds: (tFinal * 1000).round()); + _animation!.addListener(_onAnimate); + _controller.forward(); + case _GestureType.scale: + if (details.scaleVelocity.abs() < 0.1) { + _currentAxis = null; + return; + } + final double scale = + _transformationController!.value.getMaxScaleOnAxis(); + final FrictionSimulation frictionSimulation = FrictionSimulation( + widget.interactionEndFrictionCoefficient * widget.scaleFactor, + scale, + details.scaleVelocity / 10); + final double tFinal = _getFinalTime(details.scaleVelocity.abs(), + widget.interactionEndFrictionCoefficient, + effectivelyMotionless: 0.1); + _scaleAnimation = + Tween(begin: scale, end: frictionSimulation.x(tFinal)) + .animate(CurvedAnimation( + parent: _scaleController, curve: Curves.decelerate)); + _scaleController.duration = + Duration(milliseconds: (tFinal * 1000).round()); + _scaleAnimation!.addListener(_onScaleAnimate); + _scaleController.forward(); + case _GestureType.rotate || null: + break; + } + } + + // Handle mousewheel and web trackpad scroll events. + void _receivedPointerSignal(PointerSignalEvent event) { + final double scaleChange; + if (event is PointerScrollEvent) { + if (event.kind == PointerDeviceKind.trackpad && + !widget.trackpadScrollCausesScale) { + // Trackpad scroll, so treat it as a pan. + widget.onInteractionStart?.call( + ScaleStartDetails( + focalPoint: event.position, + localFocalPoint: event.localPosition, + ), + ); + + final Offset localDelta = PointerEvent.transformDeltaViaPositions( + untransformedEndPosition: event.position + event.scrollDelta, + untransformedDelta: event.scrollDelta, + transform: event.transform, + ); + + if (!_gestureIsSupported(_GestureType.pan)) { + widget.onInteractionUpdate?.call(ScaleUpdateDetails( + focalPoint: event.position - event.scrollDelta, + localFocalPoint: event.localPosition - event.scrollDelta, + focalPointDelta: -localDelta, + )); + widget.onInteractionEnd?.call(ScaleEndDetails()); + return; + } + + final Offset focalPointScene = _transformationController!.toScene( + event.localPosition, + ); + + final Offset newFocalPointScene = _transformationController!.toScene( + event.localPosition - localDelta, + ); + + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + newFocalPointScene - focalPointScene); + + widget.onInteractionUpdate?.call(ScaleUpdateDetails( + focalPoint: event.position - event.scrollDelta, + localFocalPoint: event.localPosition - localDelta, + focalPointDelta: -localDelta)); + widget.onInteractionEnd?.call(ScaleEndDetails()); + return; + } + // Ignore left and right mouse wheel scroll. + if (event.scrollDelta.dy == 0.0) { + return; + } + scaleChange = math.exp(-event.scrollDelta.dy / widget.scaleFactor); + } else if (event is PointerScaleEvent) { + scaleChange = event.scale; + } else { + return; + } + widget.onInteractionStart?.call( + ScaleStartDetails( + focalPoint: event.position, + localFocalPoint: event.localPosition, + ), + ); + + if (!_gestureIsSupported(_GestureType.scale)) { + widget.onInteractionUpdate?.call(ScaleUpdateDetails( + focalPoint: event.position, + localFocalPoint: event.localPosition, + scale: scaleChange, + )); + widget.onInteractionEnd?.call(ScaleEndDetails()); + return; + } + + final Offset focalPointScene = _transformationController!.toScene( + event.localPosition, + ); + + _transformationController!.value = _matrixScale( + _transformationController!.value, + scaleChange, + ); + + // After scaling, translate such that the event's position is at the + // same scene point before and after the scale. + final Offset focalPointSceneScaled = _transformationController!.toScene( + event.localPosition, + ); + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + focalPointSceneScaled - focalPointScene, + ); + + widget.onInteractionUpdate?.call(ScaleUpdateDetails( + focalPoint: event.position, + localFocalPoint: event.localPosition, + scale: scaleChange, + )); + widget.onInteractionEnd?.call(ScaleEndDetails()); + } + + // Handle inertia drag animation. + void _onAnimate() { + if (!_controller.isAnimating) { + _currentAxis = null; + _animation?.removeListener(_onAnimate); + _animation = null; + _controller.reset(); + return; + } + // Translate such that the resulting translation is _animation.value. + final Vector3 translationVector = + _transformationController!.value.getTranslation(); + final Offset translation = Offset(translationVector.x, translationVector.y); + final Offset translationScene = _transformationController!.toScene( + translation, + ); + final Offset animationScene = _transformationController!.toScene( + _animation!.value, + ); + final Offset translationChangeScene = animationScene - translationScene; + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + translationChangeScene, + ); + } + + // Handle inertia scale animation. + void _onScaleAnimate() { + if (!_scaleController.isAnimating) { + _currentAxis = null; + _scaleAnimation?.removeListener(_onScaleAnimate); + _scaleAnimation = null; + _scaleController.reset(); + return; + } + final double desiredScale = _scaleAnimation!.value; + final double scaleChange = + desiredScale / _transformationController!.value.getMaxScaleOnAxis(); + final Offset referenceFocalPoint = _transformationController!.toScene( + _scaleAnimationFocalPoint, + ); + _transformationController!.value = _matrixScale( + _transformationController!.value, + scaleChange, + ); + + // While scaling, translate such that the user's two fingers stay on + // the same places in the scene. That means that the focal point of + // the scale should be on the same place in the scene before and after + // the scale. + final Offset focalPointSceneScaled = _transformationController!.toScene( + _scaleAnimationFocalPoint, + ); + _transformationController!.value = _matrixTranslate( + _transformationController!.value, + focalPointSceneScaled - referenceFocalPoint, + ); + } + + void _onTransformationControllerChange() { + // A change to the TransformationController's value is a change to the + // state. + setState(() {}); + } + + @override + void initState() { + super.initState(); + + _transformationController = + widget.transformationController ?? TransformationController(); + _transformationController!.addListener(_onTransformationControllerChange); + _controller = AnimationController( + vsync: this, + ); + _scaleController = AnimationController(vsync: this); + } + + @override + void didUpdateWidget(InteractiveViewer oldWidget) { + super.didUpdateWidget(oldWidget); + // Handle all cases of needing to dispose and initialize + // transformationControllers. + if (oldWidget.transformationController == null) { + if (widget.transformationController != null) { + _transformationController! + .removeListener(_onTransformationControllerChange); + _transformationController!.dispose(); + _transformationController = widget.transformationController; + _transformationController! + .addListener(_onTransformationControllerChange); + } + } else { + if (widget.transformationController == null) { + _transformationController! + .removeListener(_onTransformationControllerChange); + _transformationController = TransformationController(); + _transformationController! + .addListener(_onTransformationControllerChange); + } else if (widget.transformationController != + oldWidget.transformationController) { + _transformationController! + .removeListener(_onTransformationControllerChange); + _transformationController = widget.transformationController; + _transformationController! + .addListener(_onTransformationControllerChange); + } + } + } + + @override + void dispose() { + _controller.dispose(); + _scaleController.dispose(); + _transformationController! + .removeListener(_onTransformationControllerChange); + if (widget.transformationController == null) { + _transformationController!.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget child; + if (widget.child != null) { + child = _InteractiveViewerBuilt( + childKey: _childKey, + clipBehavior: widget.clipBehavior, + constrained: widget.constrained, + matrix: _transformationController!.value, + alignment: widget.alignment, + child: widget.child!, + ); + } else { + // When using InteractiveViewer.builder, then constrained is false and the + // viewport is the size of the constraints. + assert(widget.builder != null); + assert(!widget.constrained); + child = LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final Matrix4 matrix = _transformationController!.value; + return _InteractiveViewerBuilt( + childKey: _childKey, + clipBehavior: widget.clipBehavior, + constrained: widget.constrained, + alignment: widget.alignment, + matrix: matrix, + child: widget.builder!( + context, + _transformViewport(matrix, Offset.zero & constraints.biggest), + ), + ); + }, + ); + } + + return Listener( + key: _parentKey, + onPointerSignal: _receivedPointerSignal, + child: GestureDetector( + behavior: + HitTestBehavior.translucent, // Necessary when panning off screen. + onScaleEnd: _onScaleEnd, + onScaleStart: _onScaleStart, + onScaleUpdate: _onScaleUpdate, + trackpadScrollCausesScale: widget.trackpadScrollCausesScale, + trackpadScrollToScaleFactor: Offset(0, -1 / widget.scaleFactor), + child: child, + ), + ); + } +} + +// This widget allows us to easily swap in and out the LayoutBuilder in +// InteractiveViewer's depending on if it's using a builder or a child. +class _InteractiveViewerBuilt extends StatelessWidget { + const _InteractiveViewerBuilt({ + required this.child, + required this.childKey, + required this.clipBehavior, + required this.constrained, + required this.matrix, + required this.alignment, + }); + + final Widget child; + final GlobalKey childKey; + final Clip clipBehavior; + final bool constrained; + final Matrix4 matrix; + final Alignment? alignment; + + @override + Widget build(BuildContext context) { + Widget child = Transform( + transform: matrix, + alignment: alignment, + child: KeyedSubtree( + key: childKey, + child: this.child, + ), + ); + + if (!constrained) { + child = OverflowBox( + alignment: Alignment.topLeft, + minWidth: 0.0, + minHeight: 0.0, + maxWidth: double.infinity, + maxHeight: double.infinity, + child: child, + ); + } + + return ClipRect( + clipBehavior: clipBehavior, + child: child, + ); + } +} + +/// A thin wrapper on [ValueNotifier] whose value is a [Matrix4] representing a +/// transformation. +/// +/// The [value] defaults to the identity matrix, which corresponds to no +/// transformation. +/// +/// See also: +/// +/// * [InteractiveViewer.transformationController] for detailed documentation +/// on how to use TransformationController with [InteractiveViewer]. +class TransformationController extends ValueNotifier { + /// Create an instance of [TransformationController]. + /// + /// The [value] defaults to the identity matrix, which corresponds to no + /// transformation. + TransformationController([Matrix4? value]) + : super(value ?? Matrix4.identity()); + + /// Return the scene point at the given viewport point. + /// + /// A viewport point is relative to the parent while a scene point is relative + /// to the child, regardless of transformation. Calling toScene with a + /// viewport point essentially returns the scene coordinate that lies + /// underneath the viewport point given the transform. + /// + /// The viewport transforms as the inverse of the child (i.e. moving the child + /// left is equivalent to moving the viewport right). + /// + /// This method is often useful when determining where an event on the parent + /// occurs on the child. This example shows how to determine where a tap on + /// the parent occurred on the child. + /// + /// ```dart + /// @override + /// Widget build(BuildContext context) { + /// return GestureDetector( + /// onTapUp: (TapUpDetails details) { + /// _childWasTappedAt = _transformationController.toScene( + /// details.localPosition, + /// ); + /// }, + /// child: InteractiveViewer( + /// transformationController: _transformationController, + /// child: child, + /// ), + /// ); + /// } + /// ``` + Offset toScene(Offset viewportPoint) { + // On viewportPoint, perform the inverse transformation of the scene to get + // where the point would be in the scene before the transformation. + final Matrix4 inverseMatrix = Matrix4.inverted(value); + final Vector3 untransformed = inverseMatrix.transform3(Vector3( + viewportPoint.dx, + viewportPoint.dy, + 0, + )); + return Offset(untransformed.x, untransformed.y); + } +} + +// A classification of relevant user gestures. Each contiguous user gesture is +// represented by exactly one _GestureType. +enum _GestureType { + pan, + scale, + rotate, +} + +// Given a velocity and drag, calculate the time at which motion will come to +// a stop, within the margin of effectivelyMotionless. +double _getFinalTime(double velocity, double drag, + {double effectivelyMotionless = 10}) { + return math.log(effectivelyMotionless / velocity) / math.log(drag / 100); +} + +// Return the translation from the given Matrix4 as an Offset. +Offset _getMatrixTranslation(Matrix4 matrix) { + final Vector3 nextTranslation = matrix.getTranslation(); + return Offset(nextTranslation.x, nextTranslation.y); +} + +// Transform the four corners of the viewport by the inverse of the given +// matrix. This gives the viewport after the child has been transformed by the +// given matrix. The viewport transforms as the inverse of the child (i.e. +// moving the child left is equivalent to moving the viewport right). +Quad _transformViewport(Matrix4 matrix, Rect viewport) { + final Matrix4 inverseMatrix = matrix.clone()..invert(); + return Quad.points( + inverseMatrix.transform3(Vector3( + viewport.topLeft.dx, + viewport.topLeft.dy, + 0.0, + )), + inverseMatrix.transform3(Vector3( + viewport.topRight.dx, + viewport.topRight.dy, + 0.0, + )), + inverseMatrix.transform3(Vector3( + viewport.bottomRight.dx, + viewport.bottomRight.dy, + 0.0, + )), + inverseMatrix.transform3(Vector3( + viewport.bottomLeft.dx, + viewport.bottomLeft.dy, + 0.0, + )), + ); +} + +// Find the axis aligned bounding box for the rect rotated about its center by +// the given amount. +Quad _getAxisAlignedBoundingBoxWithRotation(Rect rect, double rotation) { + final Matrix4 rotationMatrix = Matrix4.identity() + ..translate(rect.size.width / 2, rect.size.height / 2) + ..rotateZ(rotation) + ..translate(-rect.size.width / 2, -rect.size.height / 2); + final Quad boundariesRotated = Quad.points( + rotationMatrix.transform3(Vector3(rect.left, rect.top, 0.0)), + rotationMatrix.transform3(Vector3(rect.right, rect.top, 0.0)), + rotationMatrix.transform3(Vector3(rect.right, rect.bottom, 0.0)), + rotationMatrix.transform3(Vector3(rect.left, rect.bottom, 0.0)), + ); + return InteractiveViewer.getAxisAlignedBoundingBox(boundariesRotated); +} + +// Return the amount that viewport lies outside of boundary. If the viewport +// is completely contained within the boundary (inclusively), then returns +// Offset.zero. +Offset _exceedsBy(Quad boundary, Quad viewport) { + final List viewportPoints = [ + viewport.point0, + viewport.point1, + viewport.point2, + viewport.point3, + ]; + Offset largestExcess = Offset.zero; + for (final Vector3 point in viewportPoints) { + final Vector3 pointInside = + InteractiveViewer.getNearestPointInside(point, boundary); + final Offset excess = Offset( + pointInside.x - point.x, + pointInside.y - point.y, + ); + if (excess.dx.abs() > largestExcess.dx.abs()) { + largestExcess = Offset(excess.dx, largestExcess.dy); + } + if (excess.dy.abs() > largestExcess.dy.abs()) { + largestExcess = Offset(largestExcess.dx, excess.dy); + } + } + + return _round(largestExcess); +} + +// Round the output values. This works around a precision problem where +// values that should have been zero were given as within 10^-10 of zero. +Offset _round(Offset offset) { + return Offset( + double.parse(offset.dx.toStringAsFixed(9)), + double.parse(offset.dy.toStringAsFixed(9)), + ); +} + +// Align the given offset to the given axis by allowing movement only in the +// axis direction. +Offset _alignAxis(Offset offset, Axis axis) { + return switch (axis) { + Axis.horizontal => Offset(offset.dx, 0.0), + Axis.vertical => Offset(0.0, offset.dy), + }; +} + +// Given two points, return the axis where the distance between the points is +// greatest. If they are equal, return null. +Axis? _getPanAxis(Offset point1, Offset point2) { + if (point1 == point2) { + return null; + } + final double x = point2.dx - point1.dx; + final double y = point2.dy - point1.dy; + return x.abs() > y.abs() ? Axis.horizontal : Axis.vertical; +} + +/// This enum is used to specify the behavior of the [InteractiveViewer] when +/// the user drags the viewport. +enum PanAxis { + /// The user can only pan the viewport along the horizontal axis. + horizontal, + + /// The user can only pan the viewport along the vertical axis. + vertical, + + /// The user can pan the viewport along the horizontal and vertical axes + /// but not diagonally. + aligned, + + /// The user can pan the viewport freely in any direction. + free, +} diff --git a/lib/common/widgets/interactiveviewer_gallery/interactive_viewer_boundary.dart b/lib/common/widgets/interactiveviewer_gallery/interactive_viewer_boundary.dart new file mode 100644 index 000000000..4dd591f2e --- /dev/null +++ b/lib/common/widgets/interactiveviewer_gallery/interactive_viewer_boundary.dart @@ -0,0 +1,236 @@ +import 'interactive_viewer.dart' as custom; +import 'package:flutter/material.dart'; + +/// https://github.com/qq326646683/interactiveviewer_gallery + +/// A callback for the [InteractiveViewerBoundary] that is called when the scale +/// changed. +typedef ScaleChanged = void Function(double scale); + +/// Builds an [InteractiveViewer] and provides callbacks that are called when a +/// horizontal boundary has been hit. +/// +/// The callbacks are called when an interaction ends by listening to the +/// [InteractiveViewer.onInteractionEnd] callback. +class InteractiveViewerBoundary extends StatefulWidget { + const InteractiveViewerBoundary({ + super.key, + required this.child, + required this.boundaryWidth, + this.controller, + this.onScaleChanged, + this.onLeftBoundaryHit, + this.onRightBoundaryHit, + this.onNoBoundaryHit, + required this.maxScale, + required this.minScale, + this.onDismissed, + this.dismissThreshold = 0.2, + }); + + final double dismissThreshold; + final VoidCallback? onDismissed; + + final Widget child; + + /// The max width this widget can have. + /// + /// If the [InteractiveViewer] can take up the entire screen width, this + /// should be set to `MediaQuery.of(context).size.width`. + final double boundaryWidth; + + /// The [TransformationController] for the [InteractiveViewer]. + final custom.TransformationController? controller; + + /// Called when the scale changed after an interaction ended. + final ScaleChanged? onScaleChanged; + + /// Called when the left boundary has been hit after an interaction ended. + final VoidCallback? onLeftBoundaryHit; + + /// Called when the right boundary has been hit after an interaction ended. + final VoidCallback? onRightBoundaryHit; + + /// Called when no boundary has been hit after an interaction ended. + final VoidCallback? onNoBoundaryHit; + + final double maxScale; + + final double minScale; + + @override + InteractiveViewerBoundaryState createState() => + InteractiveViewerBoundaryState(); +} + +class InteractiveViewerBoundaryState extends State + with SingleTickerProviderStateMixin { + custom.TransformationController? _controller; + + double? _scale; + + late AnimationController _animateController; + late Animation _slideAnimation; + late Animation _scaleAnimation; + late Animation _opacityAnimation; + + Offset _offset = Offset.zero; + bool _dragging = false; + + bool get _isActive => _dragging || _animateController.isAnimating; + + @override + void initState() { + super.initState(); + + _controller = widget.controller ?? custom.TransformationController(); + + _animateController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + _updateMoveAnimation(); + } + + @override + void dispose() { + _controller!.dispose(); + _animateController.dispose(); + + super.dispose(); + } + + void _updateMoveAnimation() { + final double endX = _offset.dx.sign * (_offset.dx.abs() / _offset.dy.abs()); + final double endY = _offset.dy.sign; + + _slideAnimation = _animateController.drive( + Tween( + begin: Offset.zero, + end: Offset(endX, endY), + ), + ); + + _scaleAnimation = _animateController.drive( + Tween( + begin: 1, + end: 0.25, + ), + ); + + _opacityAnimation = _animateController.drive( + DecorationTween( + begin: const BoxDecoration( + color: Colors.black, + ), + end: const BoxDecoration( + color: Colors.transparent, + ), + ), + ); + } + + void _handleDragStart(ScaleStartDetails details) { + _dragging = true; + + if (_animateController.isAnimating) { + _animateController.stop(); + } else { + _offset = Offset.zero; + _animateController.value = 0.0; + } + setState(_updateMoveAnimation); + } + + void _handleDragUpdate(ScaleUpdateDetails details) { + if (!_isActive || _animateController.isAnimating) { + return; + } + + _offset += details.focalPointDelta; + + setState(_updateMoveAnimation); + + if (!_animateController.isAnimating) { + _animateController.value = _offset.dy.abs() / context.size!.height; + } + } + + void _handleDragEnd(ScaleEndDetails details) { + if (!_isActive || _animateController.isAnimating) { + return; + } + + _dragging = false; + + if (_animateController.isCompleted) { + return; + } + + if (!_animateController.isDismissed) { + // if the dragged value exceeded the dismissThreshold, call onDismissed + // else animate back to initial position. + if (_animateController.value > widget.dismissThreshold) { + widget.onDismissed?.call(); + } else { + _animateController.reverse(); + } + } + } + + void _updateBoundaryDetection() { + final double scale = _controller!.value.row0[0]; + + if (_scale != scale) { + // the scale changed + _scale = scale; + widget.onScaleChanged?.call(scale); + } + + if (scale <= 1.01) { + // cant hit any boundaries when the child is not scaled + return; + } + + final double xOffset = _controller!.value.row0[3]; + final double boundaryWidth = widget.boundaryWidth; + final double boundaryEnd = boundaryWidth * scale; + final double xPos = boundaryEnd + xOffset; + + if (boundaryEnd.round() == xPos.round()) { + // left boundary hit + widget.onLeftBoundaryHit?.call(); + } else if (boundaryWidth.round() == xPos.round()) { + // right boundary hit + widget.onRightBoundaryHit?.call(); + } else { + widget.onNoBoundaryHit?.call(); + } + } + + Widget get content => DecoratedBoxTransition( + decoration: _opacityAnimation, + child: SlideTransition( + position: _slideAnimation, + child: ScaleTransition( + scale: _scaleAnimation, + child: widget.child, + ), + ), + ); + + @override + Widget build(BuildContext context) { + return custom.InteractiveViewer( + maxScale: widget.maxScale, + minScale: widget.minScale, + transformationController: _controller, + onInteractionEnd: (_) => _updateBoundaryDetection(), + onPanStart: _handleDragStart, + onPanUpdate: _handleDragUpdate, + onPanEnd: _handleDragEnd, + child: content, + ); + } +} diff --git a/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart b/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart new file mode 100644 index 000000000..c7c31e123 --- /dev/null +++ b/lib/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart @@ -0,0 +1,491 @@ +import 'dart:io'; + +import 'package:PiliPalaX/utils/download.dart'; +import 'package:PiliPalaX/utils/storage.dart'; +import 'package:PiliPalaX/utils/utils.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:status_bar_control/status_bar_control.dart'; +import 'interactive_viewer_boundary.dart'; +import 'interactive_viewer.dart' as custom; + +/// https://github.com/qq326646683/interactiveviewer_gallery + +/// Builds a carousel controlled by a [PageView] for the tweet media sources. +/// +/// Used for showing a full screen view of the [TweetMedia] sources. +/// +/// The sources can be panned and zoomed interactively using an +/// [InteractiveViewer]. +/// An [InteractiveViewerBoundary] is used to detect when the boundary of the +/// source is hit after zooming in to disable or enable the swiping gesture of +/// the [PageView]. +/// +typedef IndexedFocusedWidgetBuilder = Widget Function( + BuildContext context, int index, bool isFocus, bool enablePageView); + +typedef IndexedTagStringBuilder = String Function(int index); + +class InteractiveviewerGallery extends StatefulWidget { + const InteractiveviewerGallery({ + super.key, + required this.sources, + required this.initIndex, + this.itemBuilder, + this.maxScale = 8, + this.minScale = 1.0, + this.onPageChanged, + this.onDismissed, + }); + + /// The sources to show. + final List sources; + + /// The index of the first source in [sources] to show. + final int initIndex; + + /// The item content + final IndexedFocusedWidgetBuilder? itemBuilder; + + final double maxScale; + + final double minScale; + + final ValueChanged? onPageChanged; + + final ValueChanged? onDismissed; + + @override + State createState() => + _InteractiveviewerGalleryState(); +} + +class _InteractiveviewerGalleryState extends State + with SingleTickerProviderStateMixin { + PageController? _pageController; + custom.TransformationController? _transformationController; + + /// The controller to animate the transformation value of the + /// [InteractiveViewer] when it should reset. + late AnimationController _animationController; + Animation? _animation; + + /// `true` when an source is zoomed in and not at the at a horizontal boundary + /// to disable the [PageView]. + bool _enablePageView = true; + + late Offset _doubleTapLocalPosition; + + int? currentIndex; + + late List _thumbList; + late int _quality; + + @override + void initState() { + super.initState(); + + _quality = + GStorage.setting.get(SettingBoxKey.previewQuality, defaultValue: 80); + _thumbList = List.generate(widget.sources.length, (_) => true); + + _pageController = PageController(initialPage: widget.initIndex); + + _transformationController = custom.TransformationController(); + + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + )..addListener(() { + _transformationController!.value = + _animation?.value ?? Matrix4.identity(); + }); + + currentIndex = widget.initIndex; + setStatusBar(); + } + + setStatusBar() async { + if (Platform.isIOS || Platform.isAndroid) { + await StatusBarControl.setHidden( + true, + animation: StatusBarAnimation.FADE, + ); + } + } + + @override + void dispose() async { + _pageController?.dispose(); + _animationController.removeListener(() {}); + _animationController.dispose(); + if (Platform.isIOS || Platform.isAndroid) { + StatusBarControl.setHidden(false, animation: StatusBarAnimation.FADE); + } + super.dispose(); + } + + /// When the source gets scaled up, the swipe up / down to dismiss gets + /// disabled. + /// + /// When the scale resets, the dismiss and the page view swiping gets enabled. + void _onScaleChanged(double scale) { + final bool initialScale = scale <= widget.minScale; + + if (initialScale) { + if (!_enablePageView) { + setState(() { + _enablePageView = true; + }); + } + } else { + if (_enablePageView) { + setState(() { + _enablePageView = false; + }); + } + } + } + + /// When the left boundary has been hit after scaling up the source, the page + /// view swiping gets enabled if it has a page to swipe to. + void _onLeftBoundaryHit() { + if (!_enablePageView && _pageController!.page!.floor() > 0) { + setState(() { + _enablePageView = true; + }); + } + } + + /// When the right boundary has been hit after scaling up the source, the page + /// view swiping gets enabled if it has a page to swipe to. + void _onRightBoundaryHit() { + if (!_enablePageView && + _pageController!.page!.floor() < widget.sources.length - 1) { + setState(() { + _enablePageView = true; + }); + } + } + + /// When the source has been scaled up and no horizontal boundary has been hit, + /// the page view swiping gets disabled. + void _onNoBoundaryHit() { + if (_enablePageView) { + setState(() { + _enablePageView = false; + }); + } + } + + /// When the page view changed its page, the source will animate back into the + /// original scale if it was scaled up. + /// + /// Additionally the swipe up / down to dismiss gets enabled. + void _onPageChanged(int page) { + setState(() { + currentIndex = page; + }); + widget.onPageChanged?.call(page); + if (_transformationController!.value != Matrix4.identity()) { + // animate the reset for the transformation of the interactive viewer + + _animation = Matrix4Tween( + begin: _transformationController!.value, + end: Matrix4.identity(), + ).animate( + CurveTween(curve: Curves.easeOut).animate(_animationController), + ); + + _animationController.forward(from: 0); + } + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + InteractiveViewerBoundary( + controller: _transformationController, + boundaryWidth: MediaQuery.of(context).size.width, + onScaleChanged: _onScaleChanged, + onLeftBoundaryHit: _onLeftBoundaryHit, + onRightBoundaryHit: _onRightBoundaryHit, + onNoBoundaryHit: _onNoBoundaryHit, + maxScale: widget.maxScale, + minScale: widget.minScale, + onDismissed: () { + Get.back(); + widget.onDismissed?.call(_pageController!.page!.floor()); + }, + child: PageView.builder( + onPageChanged: _onPageChanged, + controller: _pageController, + physics: + _enablePageView ? null : const NeverScrollableScrollPhysics(), + itemCount: widget.sources.length, + itemBuilder: (BuildContext context, int index) { + return GestureDetector( + onDoubleTapDown: (TapDownDetails details) { + _doubleTapLocalPosition = details.localPosition; + }, + onDoubleTap: onDoubleTap, + onLongPress: onLongPress, + child: widget.itemBuilder != null + ? widget.itemBuilder!( + context, + index, + index == currentIndex, + _enablePageView, + ) + : _itemBuilder(widget.sources, index), + ); + }, + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: EdgeInsets.fromLTRB( + 12, + 8, + 20, + MediaQuery.of(context).padding.bottom + 8, + ), + decoration: _enablePageView + ? BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withOpacity(0.3) + ], + ), + ) + : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () { + Get.back(); + widget.onDismissed?.call(_pageController!.page!.floor()); + }, + ), + widget.sources.length > 1 + ? Text( + "${currentIndex! + 1}/${widget.sources.length}", + style: const TextStyle(color: Colors.white), + ) + : const SizedBox(), + PopupMenuButton( + itemBuilder: (context) { + return [ + PopupMenuItem( + value: 0, + onTap: () => onShareImg(widget.sources[currentIndex!]), + child: const Text("分享图片"), + ), + PopupMenuItem( + value: 1, + onTap: () { + Utils.copyText(widget.sources[currentIndex!]); + }, + child: const Text("复制链接"), + ), + PopupMenuItem( + value: 2, + onTap: () { + DownloadUtils.downloadImg( + context, + [widget.sources[currentIndex!]], + ); + }, + child: const Text("保存图片"), + ), + if (widget.sources.length > 1) + PopupMenuItem( + value: 3, + onTap: () { + DownloadUtils.downloadImg( + context, + widget.sources[currentIndex!], + ); + }, + child: const Text("保存全部图片"), + ), + ]; + }, + child: const Icon(Icons.more_horiz, color: Colors.white), + ), + ], + ), + ), + ), + ], + ); + } + + // 图片分享 + void onShareImg(String imgUrl) async { + SmartDialog.showLoading(); + var response = await Dio() + .get(imgUrl, options: Options(responseType: ResponseType.bytes)); + final temp = await getTemporaryDirectory(); + SmartDialog.dismiss(); + String imgName = + "plpl_pic_${DateTime.now().toString().split('-').join()}.jpg"; + var path = '${temp.path}/$imgName'; + File(path).writeAsBytesSync(response.data); + Share.shareXFiles([XFile(path)], subject: imgUrl); + } + + Widget _itemBuilder(sources, index) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + Get.back(); + }, + child: Center( + child: Hero( + tag: sources[index], + child: CachedNetworkImage( + fadeInDuration: const Duration(milliseconds: 0), + fadeOutDuration: const Duration(milliseconds: 0), + imageUrl: _thumbList[index] && _quality != 100 + ? '${sources[index]}@${_quality}q.webp' + : sources[index], + fit: BoxFit.contain, + progressIndicatorBuilder: (context, url, progress) { + return Container( + width: 150.0, + alignment: Alignment.center, + child: LinearProgressIndicator(value: progress.progress ?? 0), + ); + }, + errorListener: (value) { + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _thumbList[index] = false; + }); + }); + }, + ), + ), + ), + ); + } + + onDoubleTap() { + Matrix4 matrix = _transformationController!.value.clone(); + double currentScale = matrix.row0.x; + + double targetScale = widget.minScale; + + if (currentScale <= widget.minScale) { + targetScale = widget.maxScale * 0.7; + } + + double offSetX = targetScale == 1.0 + ? 0.0 + : -_doubleTapLocalPosition.dx * (targetScale - 1); + double offSetY = targetScale == 1.0 + ? 0.0 + : -_doubleTapLocalPosition.dy * (targetScale - 1); + + matrix = Matrix4.fromList([ + targetScale, + matrix.row1.x, + matrix.row2.x, + matrix.row3.x, + matrix.row0.y, + targetScale, + matrix.row2.y, + matrix.row3.y, + matrix.row0.z, + matrix.row1.z, + targetScale, + matrix.row3.z, + offSetX, + offSetY, + matrix.row2.w, + matrix.row3.w + ]); + + _animation = Matrix4Tween( + begin: _transformationController!.value, + end: matrix, + ).animate( + CurveTween(curve: Curves.easeOut).animate(_animationController), + ); + _animationController + .forward(from: 0) + .whenComplete(() => _onScaleChanged(targetScale)); + } + + onLongPress() { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + clipBehavior: Clip.hardEdge, + contentPadding: const EdgeInsets.fromLTRB(0, 12, 0, 12), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + onTap: () { + onShareImg(widget.sources[currentIndex!]); + Get.back(); + }, + dense: true, + title: const Text('分享', style: TextStyle(fontSize: 14)), + ), + ListTile( + onTap: () { + Get.back(); + Utils.copyText(widget.sources[currentIndex!]); + }, + dense: true, + title: const Text('复制链接', style: TextStyle(fontSize: 14)), + ), + ListTile( + onTap: () { + Get.back(); + DownloadUtils.downloadImg( + context, + [widget.sources[currentIndex!]], + ); + }, + dense: true, + title: const Text('保存图片', style: TextStyle(fontSize: 14)), + ), + if (widget.sources.length > 1) + ListTile( + onTap: () { + Get.back(); + DownloadUtils.downloadImg( + context, + widget.sources as List, + ); + }, + dense: true, + title: const Text('保存全部图片', style: TextStyle(fontSize: 14)), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/common/widgets/list_sheet.dart b/lib/common/widgets/list_sheet.dart index 79b7cd682..1ee1839f1 100644 --- a/lib/common/widgets/list_sheet.dart +++ b/lib/common/widgets/list_sheet.dart @@ -171,7 +171,7 @@ class _ListSheetContentState extends State ) : null, child: LayoutBuilder( - builder: (_, constraints) => NetworkImgLayer( + builder: (context, constraints) => NetworkImgLayer( radius: 6, src: episode is video.EpisodeItem ? episode.arc?.pic @@ -239,7 +239,7 @@ class _ListSheetContentState extends State ), StreamBuilder( stream: _favStream?.stream, - builder: (_, snapshot) => snapshot.hasData + builder: (context, snapshot) => snapshot.hasData ? _mediumButton( tooltip: _seasonFav == 1 ? '取消订阅' : '订阅', icon: _seasonFav == 1 @@ -312,7 +312,7 @@ class _ListSheetContentState extends State StreamBuilder( stream: _indexStream?.stream, initialData: 0, - builder: (_, snapshot) => _mediumButton( + builder: (context, snapshot) => _mediumButton( tooltip: reverse[snapshot.data] ? '正序' : '反序', icon: !reverse[snapshot.data] ? MdiIcons.sortAscending @@ -405,7 +405,7 @@ class _ListSheetContentState extends State ); }, itemScrollController: itemScrollController[i ?? 0], - separatorBuilder: (_, index) => Divider( + separatorBuilder: (context, index) => Divider( height: 1, color: Theme.of(context).dividerColor.withOpacity(0.1), ), diff --git a/lib/common/widgets/pull_to_refresh_header.dart b/lib/common/widgets/pull_to_refresh_header.dart deleted file mode 100644 index 1ae881d23..000000000 --- a/lib/common/widgets/pull_to_refresh_header.dart +++ /dev/null @@ -1,133 +0,0 @@ -// ignore_for_file: depend_on_referenced_packages - -import 'dart:math'; -import 'dart:ui' as ui show Image; - -import 'package:extended_image/extended_image.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:pull_to_refresh_notification/pull_to_refresh_notification.dart'; - -double get maxDragOffset => 100; -double hideHeight = maxDragOffset / 2.3; -double refreshHeight = maxDragOffset / 1.5; - -class PullToRefreshHeader extends StatelessWidget { - const PullToRefreshHeader( - this.info, - this.lastRefreshTime, { - this.color, - super.key, - }); - - final PullToRefreshScrollNotificationInfo? info; - final DateTime? lastRefreshTime; - final Color? color; - - @override - Widget build(BuildContext context) { - final PullToRefreshScrollNotificationInfo? infos = info; - if (infos == null) { - return const SizedBox(); - } - String text = ''; - if (infos.mode == PullToRefreshIndicatorMode.armed) { - text = 'Release to refresh'; - } else if (infos.mode == PullToRefreshIndicatorMode.refresh || - infos.mode == PullToRefreshIndicatorMode.snap) { - text = 'Loading...'; - } else if (infos.mode == PullToRefreshIndicatorMode.done) { - text = 'Refresh completed.'; - } else if (infos.mode == PullToRefreshIndicatorMode.drag) { - text = 'Pull to refresh'; - } else if (infos.mode == PullToRefreshIndicatorMode.canceled) { - text = 'Cancel refresh'; - } - - final TextStyle ts = const TextStyle( - color: Colors.grey, - ).copyWith(fontSize: 14); - - final double dragOffset = info?.dragOffset ?? 0.0; - - final DateTime time = lastRefreshTime ?? DateTime.now(); - final double top = -hideHeight + dragOffset; - return Container( - height: dragOffset, - color: color ?? Colors.transparent, - // padding: EdgeInsets.only(top: dragOffset / 3), - // padding: EdgeInsets.only(bottom: 5.0), - child: Stack( - children: [ - Positioned( - left: 0.0, - right: 0.0, - top: top, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Container( - alignment: Alignment.centerRight, - margin: const EdgeInsets.only(right: 12.0), - child: RefreshImage(top: top), - ), - ), - Column( - children: [ - Text(text, style: ts), - Text( - 'Last updated:${DateFormat('yyyy-MM-dd hh:mm').format(time)}', - style: ts.copyWith(fontSize: 14), - ) - ], - ), - const Spacer(), - ], - ), - ) - ], - ), - ); - } -} - -class RefreshImage extends StatelessWidget { - const RefreshImage({ - super.key, - required this.top, - }); - - final double top; - - @override - Widget build(BuildContext context) { - const double imageSize = 30; - return ExtendedImage.asset( - 'assets/flutterCandies_grey.png', - width: imageSize, - height: imageSize, - afterPaintImage: (Canvas canvas, Rect rect, ui.Image image, Paint paint) { - final double imageHeight = image.height.toDouble(); - final double imageWidth = image.width.toDouble(); - final Size size = rect.size; - final double y = - (1 - min(top / (refreshHeight - hideHeight), 1)) * imageHeight; - - canvas.drawImageRect( - image, - Rect.fromLTWH(0.0, y, imageWidth, imageHeight - y), - Rect.fromLTWH(rect.left, rect.top + y / imageHeight * size.height, - size.width, (imageHeight - y) / imageHeight * size.height), - Paint() - ..colorFilter = - const ColorFilter.mode(Color(0xFFea5504), BlendMode.srcIn) - ..isAntiAlias = false - ..filterQuality = FilterQuality.low, - ); - - //canvas.restore(); - }, - ); - } -} diff --git a/lib/http/bangumi.dart b/lib/http/bangumi.dart index 6dcbdaf8a..8a2a8a3f9 100644 --- a/lib/http/bangumi.dart +++ b/lib/http/bangumi.dart @@ -5,7 +5,8 @@ import 'index.dart'; class BangumiHttp { static Future bangumiList({int? page}) async { - var res = await Request().get(Api.bangumiList, data: {'page': page}); + var res = + await Request().get(Api.bangumiList, queryParameters: {'page': page}); if (res.data['code'] == 0) { BangumiListDataModel data = BangumiListDataModel.fromJson(res.data['data']); @@ -16,7 +17,8 @@ class BangumiHttp { } static Future bangumiFollow({int? mid}) async { - var res = await Request().get(Api.bangumiFollow, data: {'vmid': mid}); + var res = + await Request().get(Api.bangumiFollow, queryParameters: {'vmid': mid}); if (res.data['code'] == 0) { BangumiListDataModel data = BangumiListDataModel.fromJson(res.data['data']); diff --git a/lib/http/black.dart b/lib/http/black.dart index 555f2c3b7..45058d46e 100644 --- a/lib/http/black.dart +++ b/lib/http/black.dart @@ -5,7 +5,7 @@ import 'index.dart'; class BlackHttp { static Future blackList({required int pn, int? ps}) async { - var res = await Request().get(Api.blackLst, data: { + var res = await Request().get(Api.blackLst, queryParameters: { 'pn': pn, 'ps': ps ?? 50, 're_version': 0, diff --git a/lib/http/common.dart b/lib/http/common.dart index fac65dbf8..3e1b9f71b 100644 --- a/lib/http/common.dart +++ b/lib/http/common.dart @@ -2,7 +2,7 @@ import 'index.dart'; class CommonHttp { static Future unReadDynamic() async { - var res = await Request().get(Api.getUnreadDynamic, data: { + var res = await Request().get(Api.getUnreadDynamic, queryParameters: { 'alltype_offset': 0, 'video_offset': 0, 'article_offset': 0, diff --git a/lib/http/danmaku.dart b/lib/http/danmaku.dart index d98146ee3..eff7e72f3 100644 --- a/lib/http/danmaku.dart +++ b/lib/http/danmaku.dart @@ -16,7 +16,7 @@ class DanmakaHttp { }; var response = await Request().get( Api.webDanmaku, - data: params, + queryParameters: params, options: Options(responseType: ResponseType.bytes), ); if (response.statusCode != 200 || response.data == null) { diff --git a/lib/http/dynamics.dart b/lib/http/dynamics.dart index 26da8148f..4cdca3e05 100644 --- a/lib/http/dynamics.dart +++ b/lib/http/dynamics.dart @@ -20,7 +20,7 @@ class DynamicsHttp { data['host_mid'] = mid; data.remove('timezone_offset'); } - var res = await Request().get(Api.followDynamic, data: data); + var res = await Request().get(Api.followDynamic, queryParameters: data); if (res.data['code'] == 0) { try { DynamicsDataModel data = DynamicsDataModel.fromJson(res.data['data']); @@ -80,7 +80,7 @@ class DynamicsHttp { static Future dynamicDetail({ String? id, }) async { - var res = await Request().get(Api.dynamicDetail, data: { + var res = await Request().get(Api.dynamicDetail, queryParameters: { 'timezone_offset': -480, 'id': id, 'features': 'itemOpusStyle', diff --git a/lib/http/fan.dart b/lib/http/fan.dart index d0c1b44b1..d529a2531 100644 --- a/lib/http/fan.dart +++ b/lib/http/fan.dart @@ -6,7 +6,7 @@ import 'index.dart'; class FanHttp { static Future fans( {int? vmid, int? pn, int? ps, String? orderType}) async { - var res = await Request().get(Api.fans, data: { + var res = await Request().get(Api.fans, queryParameters: { 'vmid': vmid, 'pn': pn, 'ps': ps, diff --git a/lib/http/follow.dart b/lib/http/follow.dart index 316aa95af..11c597d30 100644 --- a/lib/http/follow.dart +++ b/lib/http/follow.dart @@ -4,7 +4,7 @@ import 'index.dart'; class FollowHttp { static Future followings( {int? vmid, int? pn, int? ps, String? orderType}) async { - var res = await Request().get(Api.followings, data: { + var res = await Request().get(Api.followings, queryParameters: { 'vmid': vmid, 'pn': pn, 'ps': ps, diff --git a/lib/http/init.dart b/lib/http/init.dart index 943f1d1e6..154661214 100644 --- a/lib/http/init.dart +++ b/lib/http/init.dart @@ -147,7 +147,7 @@ class Request { // ..httpClientAdapter = Http2Adapter( // ConnectionManager( // idleTimeout: const Duration(milliseconds: 10000), - // onClientCreate: (_, ClientSetting config) => + // onClientCreate: (context, ClientSetting config) => // config.onBadCertificate = (_) => true, // ), // ); @@ -189,7 +189,8 @@ class Request { /* * get请求 */ - Future get(url, {data, options, cancelToken, extra}) async { + Future get(url, + {queryParameters, options, cancelToken, extra}) async { Response response; if (extra != null) { if (extra['ua'] != null) { @@ -202,7 +203,7 @@ class Request { try { response = await dio.get( url, - queryParameters: data, + queryParameters: queryParameters, options: options, cancelToken: cancelToken, ); diff --git a/lib/http/live.dart b/lib/http/live.dart index bcfb836c6..a1043c468 100644 --- a/lib/http/live.dart +++ b/lib/http/live.dart @@ -12,7 +12,7 @@ class LiveHttp { static Future liveList( {int? vmid, int? pn, int? ps, String? orderType}) async { var res = await Request().get(Api.liveList, - data: {'page': pn, 'page_size': 30, 'platform': 'web'}); + queryParameters: {'page': pn, 'page_size': 30, 'platform': 'web'}); if (res.data['code'] == 0) { List list = res.data['data']['list'] .map((e) => LiveItemModel.fromJson(e)) @@ -67,7 +67,7 @@ class LiveHttp { } static Future liveRoomInfo({roomId, qn}) async { - var res = await Request().get(Api.liveRoomInfo, data: { + var res = await Request().get(Api.liveRoomInfo, queryParameters: { 'room_id': roomId, 'protocol': '0, 1', 'format': '0, 1, 2', @@ -90,7 +90,7 @@ class LiveHttp { } static Future liveRoomInfoH5({roomId, qn}) async { - var res = await Request().get(Api.liveRoomInfoH5, data: { + var res = await Request().get(Api.liveRoomInfoH5, queryParameters: { 'room_id': roomId, }); if (res.data['code'] == 0) { @@ -108,7 +108,7 @@ class LiveHttp { } static Future liveRoomDanmaPrefetch({roomId}) async { - var res = await Request().get(Api.liveRoomDmPrefetch, data: { + var res = await Request().get(Api.liveRoomDmPrefetch, queryParameters: { 'roomid': roomId, }); if (res.data['code'] == 0) { @@ -123,7 +123,7 @@ class LiveHttp { } static Future liveRoomGetDanmakuToken({roomId}) async { - var res = await Request().get(Api.liveRoomDmToken, data: { + var res = await Request().get(Api.liveRoomDmToken, queryParameters: { 'id': roomId, }); if (res.data['code'] == 0) { diff --git a/lib/http/login.dart b/lib/http/login.dart index ccea4d3a6..91191ca6c 100644 --- a/lib/http/login.dart +++ b/lib/http/login.dart @@ -349,7 +349,7 @@ class LoginHttp { static Future safeCenterGetInfo({ required String tmpCode, }) async { - var res = await Request().get(Api.safeCenterGetInfo, data: { + var res = await Request().get(Api.safeCenterGetInfo, queryParameters: { 'tmp_code': tmpCode, }); if (res.data['code'] == 0) { diff --git a/lib/http/member.dart b/lib/http/member.dart index e3a493527..640ed1f82 100644 --- a/lib/http/member.dart +++ b/lib/http/member.dart @@ -91,7 +91,7 @@ class MemberHttp { int? _mid = GStorage.userInfo.get('userInfoCache')?.mid; dynamic res = await Request().get( Api.spaceArticle, - data: data, + queryParameters: data, options: Options( headers: { 'env': 'prod', @@ -136,7 +136,7 @@ class MemberHttp { int? _mid = GStorage.userInfo.get('userInfoCache')?.mid; dynamic res = await Request().get( Api.spaceFav, - data: data, + queryParameters: data, options: Options( headers: { 'env': 'prod', @@ -206,7 +206,7 @@ class MemberHttp { : type == ContributeType.series ? Api.spaceSeries : Api.spaceBangumi, - data: data, + queryParameters: data, options: Options( headers: { 'env': 'prod', @@ -251,7 +251,7 @@ class MemberHttp { int? _mid = GStorage.userInfo.get('userInfoCache')?.mid; dynamic res = await Request().get( Api.space, - data: data, + queryParameters: data, options: Options( headers: { 'env': 'prod', @@ -284,7 +284,7 @@ class MemberHttp { }); var res = await Request().get( Api.memberInfo, - data: params, + queryParameters: params, extra: {'ua': 'pc'}, ); if (res.data['code'] == 0) { @@ -302,7 +302,7 @@ class MemberHttp { } static Future memberStat({int? mid}) async { - var res = await Request().get(Api.userStat, data: {'vmid': mid}); + var res = await Request().get(Api.userStat, queryParameters: {'vmid': mid}); if (res.data['code'] == 0) { return {'status': true, 'data': res.data['data']}; } else { @@ -316,7 +316,7 @@ class MemberHttp { static Future memberCardInfo({int? mid}) async { var res = await Request() - .get(Api.memberCardInfo, data: {'mid': mid, 'photo': true}); + .get(Api.memberCardInfo, queryParameters: {'mid': mid, 'photo': true}); if (res.data['code'] == 0) { return {'status': true, 'data': res.data['data']}; } else { @@ -358,7 +358,7 @@ class MemberHttp { }); var res = await Request().get( Api.memberArchive, - data: params, + queryParameters: params, extra: {'ua': 'Mozilla/5.0'}, ); if (res.data['code'] == 0) { @@ -395,7 +395,7 @@ class MemberHttp { 'x-bili-device-req-json': jsonEncode({"platform": "web", "device": "pc"}), 'x-bili-web-req-json': jsonEncode({"spm_id": "333.999"}), }); - var res = await Request().get(Api.memberDynamic, data: params); + var res = await Request().get(Api.memberDynamic, queryParameters: params); if (res.data['code'] == 0) { return LoadingState.success(DynamicsDataModel.fromJson(res.data['data'])); } else { @@ -414,7 +414,7 @@ class MemberHttp { int? mid, required String keyword, }) async { - var res = await Request().get(Api.memberDynamicSearch, data: { + var res = await Request().get(Api.memberDynamicSearch, queryParameters: { 'keyword': keyword, 'mid': mid, 'pn': pn, @@ -510,7 +510,7 @@ class MemberHttp { int? pn, int? ps, ) async { - var res = await Request().get(Api.followUpGroup, data: { + var res = await Request().get(Api.followUpGroup, queryParameters: { 'mid': mid, 'tagid': tagid, 'pn': pn, @@ -554,7 +554,7 @@ class MemberHttp { // 获取uo专栏 static Future getMemberSeasons(int? mid, int? pn, int? ps) async { - var res = await Request().get(Api.getMemberSeasonsApi, data: { + var res = await Request().get(Api.getMemberSeasonsApi, queryParameters: { 'mid': mid, 'page_num': pn, 'page_size': ps, @@ -582,7 +582,7 @@ class MemberHttp { }); var res = await Request().get( Api.getRecentCoinVideoApi, - data: { + queryParameters: { 'vmid': mid, 'gaia_source': 'main_web', 'web_location': 333.999, @@ -615,7 +615,7 @@ class MemberHttp { }); var res = await Request().get( Api.getRecentLikeVideoApi, - data: { + queryParameters: { 'vmid': mid, 'gaia_source': 'main_web', 'web_location': 333.999, @@ -647,7 +647,7 @@ class MemberHttp { }) async { var res = await Request().get( Api.getSeasonDetailApi, - data: { + queryParameters: { 'mid': mid, 'season_id': seasonId, 'sort_reverse': sortReverse, @@ -675,7 +675,8 @@ class MemberHttp { // 获取up播放数、点赞数 static Future memberView({required int mid}) async { - var res = await Request().get(Api.getMemberViewApi, data: {'mid': mid}); + var res = await Request() + .get(Api.getMemberViewApi, queryParameters: {'mid': mid}); if (res.data['code'] == 0) { return {'status': true, 'data': res.data['data']}; } else { @@ -705,7 +706,7 @@ class MemberHttp { 'web_location': 333.999, }; Map params = await WbiSign().makSign(data); - var res = await Request().get(Api.followSearch, data: { + var res = await Request().get(Api.followSearch, queryParameters: { ...data, 'w_rid': params['w_rid'], 'wts': params['wts'], diff --git a/lib/http/msg.dart b/lib/http/msg.dart index f2d9be6e6..a3309b7a5 100644 --- a/lib/http/msg.dart +++ b/lib/http/msg.dart @@ -12,7 +12,7 @@ import 'init.dart'; class MsgHttp { static Future msgFeedReplyMe({int cursor = -1, int cursorTime = -1}) async { - var res = await Request().get(Api.msgFeedReply, data: { + var res = await Request().get(Api.msgFeedReply, queryParameters: { 'id': cursor == -1 ? null : cursor, 'reply_time': cursorTime == -1 ? null : cursorTime, }); @@ -31,7 +31,7 @@ class MsgHttp { } static Future msgFeedAtMe({int cursor = -1, int cursorTime = -1}) async { - var res = await Request().get(Api.msgFeedAt, data: { + var res = await Request().get(Api.msgFeedAt, queryParameters: { 'id': cursor == -1 ? null : cursor, 'at_time': cursorTime == -1 ? null : cursorTime, }); @@ -50,7 +50,7 @@ class MsgHttp { } static Future msgFeedLikeMe({int cursor = -1, int cursorTime = -1}) async { - var res = await Request().get(Api.msgFeedLike, data: { + var res = await Request().get(Api.msgFeedLike, queryParameters: { 'id': cursor == -1 ? null : cursor, 'like_time': cursorTime == -1 ? null : cursorTime, }); @@ -70,7 +70,7 @@ class MsgHttp { static Future msgFeedSysUserNotify() async { String csrf = await Request.getCsrf(); - var res = await Request().get(Api.msgSysUserNotify, data: { + var res = await Request().get(Api.msgSysUserNotify, queryParameters: { 'csrf': csrf, 'page_size': 20, }); @@ -90,7 +90,7 @@ class MsgHttp { static Future msgFeedSysUnifiedNotify() async { String csrf = await Request.getCsrf(); - var res = await Request().get(Api.msgSysUnifiedNotify, data: { + var res = await Request().get(Api.msgSysUnifiedNotify, queryParameters: { 'csrf': csrf, 'page_size': 10, }); @@ -110,7 +110,7 @@ class MsgHttp { static Future msgSysUpdateCursor(int cursor) async { String csrf = await Request.getCsrf(); - var res = await Request().get(Api.msgSysUpdateCursor, data: { + var res = await Request().get(Api.msgSysUpdateCursor, queryParameters: { 'csrf': csrf, 'cursor': cursor, }); @@ -400,7 +400,7 @@ class MsgHttp { } Map signParams = await WbiSign().makSign(params); - var res = await Request().get(Api.sessionList, data: signParams); + var res = await Request().get(Api.sessionList, queryParameters: signParams); if (res.data['code'] == 0) { try { return { @@ -424,7 +424,7 @@ class MsgHttp { } static Future accountList(uids) async { - var res = await Request().get(Api.sessionAccountList, data: { + var res = await Request().get(Api.sessionAccountList, queryParameters: { 'uids': uids, 'build': 0, 'mobi_app': 'web', @@ -460,7 +460,7 @@ class MsgHttp { 'build': 0, 'mobi_app': 'web', }); - var res = await Request().get(Api.sessionMsg, data: params); + var res = await Request().get(Api.sessionMsg, queryParameters: params); if (res.data['code'] == 0) { try { return { @@ -494,7 +494,7 @@ class MsgHttp { 'csrf_token': csrf, 'csrf': csrf }); - var res = await Request().get(Api.ackSessionMsg, data: params); + var res = await Request().get(Api.ackSessionMsg, queryParameters: params); if (res.data['code'] == 0) { return { 'status': true, diff --git a/lib/http/reply.dart b/lib/http/reply.dart index bbbac34f8..5868c3d8b 100644 --- a/lib/http/reply.dart +++ b/lib/http/reply.dart @@ -28,7 +28,7 @@ class ReplyHttp { var res = !isLogin ? await Request().get( '${HttpString.apiBaseUrl}${Api.replyList}/main', - data: { + queryParameters: { 'oid': oid, 'type': type, 'pagination_str': @@ -39,7 +39,7 @@ class ReplyHttp { ) : await Request().get( '${HttpString.apiBaseUrl}${Api.replyList}', - data: { + queryParameters: { 'oid': oid, 'type': type, 'sort': sort, @@ -82,7 +82,7 @@ class ReplyHttp { : null; var res = await Request().get( '${HttpString.apiBaseUrl}${Api.replyReplyList}', - data: { + queryParameters: { 'oid': oid, 'root': root, 'pn': pageNum, @@ -200,7 +200,7 @@ class ReplyHttp { } static Future getEmoteList({String? business}) async { - var res = await Request().get(Api.myEmote, data: { + var res = await Request().get(Api.myEmote, queryParameters: { 'business': business ?? 'reply', 'web_location': '333.1245', }); diff --git a/lib/http/search.dart b/lib/http/search.dart index 76d56229c..988c37eb4 100644 --- a/lib/http/search.dart +++ b/lib/http/search.dart @@ -40,7 +40,7 @@ class SearchHttp { // 获取搜索建议 static Future searchSuggest({required term}) async { var res = await Request().get(Api.searchSuggest, - data: {'term': term, 'main_ver': 'v1', 'highlight': term}); + queryParameters: {'term': term, 'main_ver': 'v1', 'highlight': term}); if (res.data is String) { Map resultMap = json.decode(res.data); if (resultMap['code'] == 0) { @@ -98,7 +98,7 @@ class SearchHttp { if (pubBegin != null) 'pubtime_begin_s': pubBegin, if (pubEnd != null) 'pubtime_end_s': pubEnd, }; - var res = await Request().get(Api.searchByType, data: reqData); + var res = await Request().get(Api.searchByType, queryParameters: reqData); if (res.data['code'] == 0) { dynamic data; try { @@ -146,8 +146,8 @@ class SearchHttp { } else if (bvid != null) { data['bvid'] = bvid; } - final dynamic res = - await Request().get(Api.ab2c, data: {...data}); + final dynamic res = await Request() + .get(Api.ab2c, queryParameters: {...data}); if (res.data['code'] == 0) { return res.data['data'].first['cid']; } else { @@ -159,7 +159,7 @@ class SearchHttp { static Future bangumiInfoNew({int? seasonId, int? epId}) async { final dynamic res = await Request().get( Api.bangumiInfo, - data: { + queryParameters: { if (seasonId != null) 'season_id': seasonId, if (epId != null) 'ep_id': epId, }, @@ -182,8 +182,8 @@ class SearchHttp { } else if (epId != null) { data['ep_id'] = epId; } - final dynamic res = - await Request().get(Api.bangumiInfo, data: {...data}); + final dynamic res = await Request() + .get(Api.bangumiInfo, queryParameters: {...data}); if (res.data['code'] == 0) { return { 'status': true, diff --git a/lib/http/user.dart b/lib/http/user.dart index eed599eb3..1d0207589 100644 --- a/lib/http/user.dart +++ b/lib/http/user.dart @@ -15,7 +15,7 @@ import 'init.dart'; class UserHttp { static Future userStat({required int mid}) async { - var res = await Request().get(Api.userStat, data: {'vmid': mid}); + var res = await Request().get(Api.userStat, queryParameters: {'vmid': mid}); if (res.data['code'] == 0) { return {'status': true, 'data': res.data['data']}; } else { @@ -49,7 +49,7 @@ class UserHttp { required int ps, required int mid, }) async { - var res = await Request().get(Api.userFavFolder, data: { + var res = await Request().get(Api.userFavFolder, queryParameters: { 'pn': pn, 'ps': ps, 'up_mid': mid, @@ -113,7 +113,7 @@ class UserHttp { static Future folderInfo({ dynamic mediaId, }) async { - var res = await Request().get(Api.folderInfo, data: { + var res = await Request().get(Api.folderInfo, queryParameters: { 'media_id': mediaId, }); if (res.data['code'] == 0) { @@ -130,7 +130,7 @@ class UserHttp { String keyword = '', String order = 'mtime', int type = 0}) async { - var res = await Request().get(Api.userFavFolderDetail, data: { + var res = await Request().get(Api.userFavFolderDetail, queryParameters: { 'media_id': mediaId, 'pn': pn, 'ps': ps, @@ -172,7 +172,7 @@ class UserHttp { int? max, int? viewAt, }) async { - var res = await Request().get(Api.historyList, data: { + var res = await Request().get(Api.historyList, queryParameters: { 'type': 'all', 'ps': 20, 'max': max ?? 0, @@ -265,7 +265,7 @@ class UserHttp { static Future thirdLogin() async { var res = await Request().get( 'https://passport.bilibili.com/login/app/third', - data: { + queryParameters: { 'appkey': Constants.appKey, 'api': Constants.thirdApi, 'sign': Constants.thirdSign, @@ -319,7 +319,7 @@ class UserHttp { static Future hasFollow(int mid) async { var res = await Request().get( Api.hasFollow, - data: { + queryParameters: { 'fid': mid, }, ); @@ -359,7 +359,7 @@ class UserHttp { {required int pn, required String keyword}) async { var res = await Request().get( Api.searchHistory, - data: { + queryParameters: { 'pn': pn, 'keyword': keyword, 'business': 'all', @@ -378,7 +378,7 @@ class UserHttp { required int pn, required int ps, }) async { - var res = await Request().get(Api.userSubFolder, data: { + var res = await Request().get(Api.userSubFolder, queryParameters: { 'up_mid': mid, 'ps': ps, 'pn': pn, @@ -399,7 +399,7 @@ class UserHttp { required int pn, required int ps, }) async { - var res = await Request().get(Api.favSeasonList, data: { + var res = await Request().get(Api.favSeasonList, queryParameters: { 'season_id': id, 'ps': ps, 'pn': pn, @@ -419,7 +419,7 @@ class UserHttp { required int pn, required int ps, }) async { - var res = await Request().get(Api.favResourceList, data: { + var res = await Request().get(Api.favResourceList, queryParameters: { 'media_id': id, 'ps': ps, 'pn': pn, @@ -463,7 +463,8 @@ class UserHttp { } static videoTags({required String bvid}) async { - var res = await Request().get(Api.videoTags, data: {'bvid': bvid}); + var res = + await Request().get(Api.videoTags, queryParameters: {'bvid': bvid}); if (res.data['code'] == 0) { return {'status': true, 'data': res.data['data']}; } else { diff --git a/lib/http/video.dart b/lib/http/video.dart index 4be804144..68f3508bf 100644 --- a/lib/http/video.dart +++ b/lib/http/video.dart @@ -41,7 +41,7 @@ class VideoHttp { {required int ps, required int freshIdx}) async { var res = await Request().get( Api.recommendListWeb, - data: { + queryParameters: { 'version': 1, 'feed_version': 'V8', 'homepage_ver': 1, @@ -119,7 +119,7 @@ class VideoHttp { var res = await Request().get( Api.recommendListApp, - data: data, + queryParameters: data, options: Options(headers: { 'Host': 'app.bilibili.com', 'buvid': LoginHttp.buvid, @@ -165,7 +165,7 @@ class VideoHttp { {required int pn, required int ps}) async { var res = await Request().get( Api.hotList, - data: {'pn': pn, 'ps': ps}, + queryParameters: {'pn': pn, 'ps': ps}, ); if (res.data['code'] == 0) { List list = []; @@ -229,7 +229,7 @@ class VideoHttp { }); try { - var res = await Request().get(Api.videoUrl, data: params); + var res = await Request().get(Api.videoUrl, queryParameters: params); if (res.data['code'] == 0) { return { 'status': true, @@ -250,7 +250,8 @@ class VideoHttp { // 视频信息 标题、简介 static Future videoIntro({required String bvid}) async { - var res = await Request().get(Api.videoIntro, data: {'bvid': bvid}); + var res = + await Request().get(Api.videoIntro, queryParameters: {'bvid': bvid}); VideoDetailResponse result = VideoDetailResponse.fromJson(res.data); if (result.code == 0) { return { @@ -305,7 +306,7 @@ class VideoHttp { static Future videoRelation({required dynamic bvid}) async { var res = await Request().get( Api.videoRelation, - data: { + queryParameters: { 'aid': IdUtils.bv2av(bvid), 'bvid': bvid, }, @@ -325,7 +326,8 @@ class VideoHttp { // 相关视频 static Future relatedVideoList({required String bvid}) async { - var res = await Request().get(Api.relatedList, data: {'bvid': bvid}); + var res = + await Request().get(Api.relatedList, queryParameters: {'bvid': bvid}); if (res.data['code'] == 0) { List list = []; for (var i in res.data['data']) { @@ -344,7 +346,7 @@ class VideoHttp { static Future bangumiLikeCoinFav({dynamic epId}) async { var res = await Request().get( Api.bangumiLikeCoinFav, - data: {'ep_id': epId}, + queryParameters: {'ep_id': epId}, ); if (res.data['code'] == 0) { return {'status': true, 'data': res.data['data']}; @@ -355,7 +357,8 @@ class VideoHttp { // 获取点赞状态 static Future hasLikeVideo({required String bvid}) async { - var res = await Request().get(Api.hasLikeVideo, data: {'bvid': bvid}); + var res = + await Request().get(Api.hasLikeVideo, queryParameters: {'bvid': bvid}); if (res.data['code'] == 0) { return {'status': true, 'data': res.data['data']}; } else { @@ -365,7 +368,8 @@ class VideoHttp { // 获取投币状态 static Future hasCoinVideo({required String bvid}) async { - var res = await Request().get(Api.hasCoinVideo, data: {'bvid': bvid}); + var res = + await Request().get(Api.hasCoinVideo, queryParameters: {'bvid': bvid}); debugPrint('res: $res'); if (res.data['code'] == 0) { return {'status': true, 'data': res.data['data']}; @@ -397,7 +401,8 @@ class VideoHttp { // 获取收藏状态 static Future hasFavVideo({required int aid}) async { - var res = await Request().get(Api.hasFavVideo, data: {'aid': aid}); + var res = + await Request().get(Api.hasFavVideo, queryParameters: {'aid': aid}); if (res.data['code'] == 0) { return {'status': true, 'data': res.data['data']}; } else { @@ -507,7 +512,7 @@ class VideoHttp { return {'status': false, 'msg': "请退出账号后重新登录"}; } assert((reasonId != null) ^ (feedbackId != null)); - var res = await Request().get(Api.feedDislike, data: { + var res = await Request().get(Api.feedDislike, queryParameters: { 'goto': goto, 'id': id, // 'mid': mid, @@ -537,7 +542,7 @@ class VideoHttp { return {'status': false, 'msg': "请退出账号后重新登录"}; } // assert ((reasonId != null) ^ (feedbackId != null)); - var res = await Request().get(Api.feedDislikeCancel, data: { + var res = await Request().get(Api.feedDislikeCancel, queryParameters: { 'goto': goto, 'id': id, // 'mid': mid, @@ -637,7 +642,7 @@ class VideoHttp { }) async { var res = await Request().get( Api.videoInFolder, - data: { + queryParameters: { 'up_mid': mid, 'rid': rid, if (type != null) 'type': type, @@ -712,7 +717,7 @@ class VideoHttp { // 查询是否关注up static Future hasFollow({required int mid}) async { - var res = await Request().get(Api.hasFollow, data: {'fid': mid}); + var res = await Request().get(Api.hasFollow, queryParameters: {'fid': mid}); if (res.data['code'] == 0) { return {'status': true, 'data': res.data['data']}; } else { @@ -818,7 +823,7 @@ class VideoHttp { // 查看视频同时在看人数 static Future onlineTotal({int? aid, String? bvid, int? cid}) async { - var res = await Request().get(Api.onlineTotal, data: { + var res = await Request().get(Api.onlineTotal, queryParameters: { 'aid': aid, 'bvid': bvid, 'cid': cid, @@ -840,7 +845,7 @@ class VideoHttp { 'cid': cid, 'up_mid': upMid, }); - var res = await Request().get(Api.aiConclusion, data: params); + var res = await Request().get(Api.aiConclusion, queryParameters: params); if (res.data['code'] == 0 && res.data['data']['code'] == 0) { return { 'status': true, @@ -856,7 +861,7 @@ class VideoHttp { assert(aid != null || bvid != null); var res = await Request().get( Api.subtitleUrl, - data: { + queryParameters: { if (aid != null) 'aid': aid, if (bvid != null) 'bvid': bvid, 'cid': cid, diff --git a/lib/main.dart b/lib/main.dart index 9e7893999..5fe7734cb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -54,8 +54,8 @@ void main() async { Request(); await Request.setCookie(); RecommendFilter(); - SmartDialog.config.toast = - SmartConfigToast(displayType: SmartToastType.normal); + SmartDialog.config.loading = + SmartConfigLoading(backType: SmartBackType.normal); // 异常捕获 logo记录 final Catcher2Options debugConfig = Catcher2Options( SilentReportMode(), diff --git a/lib/pages/bangumi/introduction/view.dart b/lib/pages/bangumi/introduction/view.dart index 713e948d5..374c364f4 100644 --- a/lib/pages/bangumi/introduction/view.dart +++ b/lib/pages/bangumi/introduction/view.dart @@ -292,7 +292,7 @@ class _BangumiInfoState extends State .isFollowed.value) { showDialog( context: context, - builder: (_) => + builder: (context) => _followDialog(), ); } else { diff --git a/lib/pages/dynamics/view.dart b/lib/pages/dynamics/view.dart index e969b3ff6..b98970c3c 100644 --- a/lib/pages/dynamics/view.dart +++ b/lib/pages/dynamics/view.dart @@ -63,7 +63,7 @@ class _DynamicsPageState extends State context: context, useSafeArea: true, isScrollControlled: true, - builder: (_) => const CreatePanel(), + builder: (context) => const CreatePanel(), ); } }, @@ -356,7 +356,7 @@ class _CreatePanelState extends State { StreamBuilder( initialData: false, stream: _isEnableStream.stream, - builder: (_, snapshot) => FilledButton.tonal( + builder: (context, snapshot) => FilledButton.tonal( onPressed: snapshot.data == true ? _onCreate : null, style: FilledButton.styleFrom( padding: @@ -536,7 +536,7 @@ class _CreatePanelState extends State { StreamBuilder( initialData: const [], stream: _pathStream.stream, - builder: (_, snapshot) => SizedBox( + builder: (context, snapshot) => SizedBox( height: 75, child: ListView.separated( scrollDirection: Axis.horizontal, @@ -619,7 +619,7 @@ class _CreatePanelState extends State { ); } }, - separatorBuilder: (_, index) => const SizedBox(width: 10), + separatorBuilder: (context, index) => const SizedBox(width: 10), ), ), ), diff --git a/lib/pages/dynamics/widgets/action_panel.dart b/lib/pages/dynamics/widgets/action_panel.dart index 49a645dbe..1063a39a3 100644 --- a/lib/pages/dynamics/widgets/action_panel.dart +++ b/lib/pages/dynamics/widgets/action_panel.dart @@ -88,7 +88,7 @@ class _ActionPanelState extends State { context: context, isScrollControlled: true, useSafeArea: true, - builder: (_) => RepostPanel( + builder: (context) => RepostPanel( item: widget.item, callback: () { int count = int.tryParse( diff --git a/lib/pages/dynamics/widgets/author_panel.dart b/lib/pages/dynamics/widgets/author_panel.dart index 82a3187a8..d11665106 100644 --- a/lib/pages/dynamics/widgets/author_panel.dart +++ b/lib/pages/dynamics/widgets/author_panel.dart @@ -240,7 +240,7 @@ class MorePanel extends StatelessWidget { Get.back(); showDialog( context: context, - builder: (_) => AlertDialog( + builder: (context) => AlertDialog( title: const Text('确定删除该动态?'), actions: [ TextButton( diff --git a/lib/pages/dynamics/widgets/author_panel_grpc.dart b/lib/pages/dynamics/widgets/author_panel_grpc.dart index 9bd3f6975..2441ca63f 100644 --- a/lib/pages/dynamics/widgets/author_panel_grpc.dart +++ b/lib/pages/dynamics/widgets/author_panel_grpc.dart @@ -241,7 +241,7 @@ class MorePanel extends StatelessWidget { Get.back(); showDialog( context: context, - builder: (_) => AlertDialog( + builder: (context) => AlertDialog( title: const Text('确定删除该动态?'), actions: [ TextButton( diff --git a/lib/pages/dynamics/widgets/content_panel.dart b/lib/pages/dynamics/widgets/content_panel.dart index 8c36f0774..e14720dce 100644 --- a/lib/pages/dynamics/widgets/content_panel.dart +++ b/lib/pages/dynamics/widgets/content_panel.dart @@ -17,7 +17,7 @@ class Content extends StatelessWidget { InlineSpan picsNodes() { return WidgetSpan( child: LayoutBuilder( - builder: (_, constraints) => image( + builder: (context, constraints) => imageview( constraints.maxWidth, (item.modules.moduleDynamic.major.opus.pics as List) .map( diff --git a/lib/pages/dynamics/widgets/content_panel_grpc.dart b/lib/pages/dynamics/widgets/content_panel_grpc.dart index 34a5d9c8b..0e6e5da78 100644 --- a/lib/pages/dynamics/widgets/content_panel_grpc.dart +++ b/lib/pages/dynamics/widgets/content_panel_grpc.dart @@ -18,7 +18,7 @@ class ContentGrpc extends StatelessWidget { InlineSpan picsNodes() { return WidgetSpan( child: LayoutBuilder( - builder: (_, constraints) => image( + builder: (context, constraints) => imageview( constraints.maxWidth, item.modules.first.moduleDynamic.dynDraw.items .map( diff --git a/lib/pages/dynamics/widgets/forward_panel.dart b/lib/pages/dynamics/widgets/forward_panel.dart index 65e3fd3fb..a08e7ca7b 100644 --- a/lib/pages/dynamics/widgets/forward_panel.dart +++ b/lib/pages/dynamics/widgets/forward_panel.dart @@ -17,7 +17,7 @@ import 'video_panel.dart'; InlineSpan picsNodes(List pics) { return WidgetSpan( child: LayoutBuilder( - builder: (_, constraints) => image( + builder: (context, constraints) => imageview( constraints.maxWidth, pics .map( diff --git a/lib/pages/dynamics/widgets/pic_panel.dart b/lib/pages/dynamics/widgets/pic_panel.dart index 0652d0544..76702b84e 100644 --- a/lib/pages/dynamics/widgets/pic_panel.dart +++ b/lib/pages/dynamics/widgets/pic_panel.dart @@ -9,7 +9,7 @@ Widget picWidget(item, context) { return const SizedBox(); } return LayoutBuilder( - builder: (_, constraints) => image( + builder: (context, constraints) => imageview( constraints.maxWidth, (item.modules.moduleDynamic.major.draw.items as List) .map( diff --git a/lib/pages/fav_detail/view.dart b/lib/pages/fav_detail/view.dart index 65d994354..69ff9feba 100644 --- a/lib/pages/fav_detail/view.dart +++ b/lib/pages/fav_detail/view.dart @@ -352,7 +352,8 @@ class _FavDetailPageState extends State { bottom: 0, child: IgnorePointer( child: LayoutBuilder( - builder: (_, constraints) => AnimatedOpacity( + builder: (context, constraints) => + AnimatedOpacity( opacity: loadingState.response[index].checked ? 1 : 0, diff --git a/lib/pages/home/controller.dart b/lib/pages/home/controller.dart index afdbe3e54..44ce47689 100644 --- a/lib/pages/home/controller.dart +++ b/lib/pages/home/controller.dart @@ -110,7 +110,7 @@ class HomeController extends GetxController with GetTickerProviderStateMixin { showDialog( context: context, useSafeArea: true, - builder: (_) => const Dialog( + builder: (context) => const Dialog( child: MinePage(), )); } diff --git a/lib/pages/home/widgets/app_bar.dart b/lib/pages/home/widgets/app_bar.dart index 34359dd70..8aea9b51b 100644 --- a/lib/pages/home/widgets/app_bar.dart +++ b/lib/pages/home/widgets/app_bar.dart @@ -59,7 +59,7 @@ class HomeAppBar extends StatelessWidget { GestureDetector( onTap: () => showModalBottomSheet( context: context, - builder: (_) => const SizedBox( + builder: (context) => const SizedBox( height: 450, child: MinePage(), ), @@ -80,7 +80,7 @@ class HomeAppBar extends StatelessWidget { tooltip: '登录', onPressed: () => showModalBottomSheet( context: context, - builder: (_) => const SizedBox( + builder: (context) => const SizedBox( height: 450, child: MinePage(), ), diff --git a/lib/pages/html/view.dart b/lib/pages/html/view.dart index d327e4b52..1e9127b10 100644 --- a/lib/pages/html/view.dart +++ b/lib/pages/html/view.dart @@ -525,7 +525,7 @@ class _HtmlRenderPageState extends State ) : SliverToBoxAdapter( child: LayoutBuilder( - builder: (_, constraints) => htmlRender( + builder: (context, constraints) => htmlRender( context: context, htmlContent: _htmlRenderCtr.response['content'], constrainedWidth: constraints.maxWidth, diff --git a/lib/pages/later/view.dart b/lib/pages/later/view.dart index 7f884ce8b..ca23f038b 100644 --- a/lib/pages/later/view.dart +++ b/lib/pages/later/view.dart @@ -168,7 +168,7 @@ class _LaterPageState extends State { bottom: 0, child: IgnorePointer( child: LayoutBuilder( - builder: (_, constraints) => AnimatedOpacity( + builder: (context, constraints) => AnimatedOpacity( opacity: videoItem.checked ? 1 : 0, duration: const Duration(milliseconds: 200), child: Container( diff --git a/lib/pages/live_room/widgets/chat.dart b/lib/pages/live_room/widgets/chat.dart index 56411f419..a3f815e6c 100644 --- a/lib/pages/live_room/widgets/chat.dart +++ b/lib/pages/live_room/widgets/chat.dart @@ -30,7 +30,7 @@ class _LiveRoomChatState extends State { () => ListView.separated( padding: const EdgeInsets.all(0), controller: widget.liveRoomController.scrollController, - separatorBuilder: (_, index) => const SizedBox(height: 6), + separatorBuilder: (context, index) => const SizedBox(height: 6), itemCount: widget.liveRoomController.messages.length, itemBuilder: (context, index) { return Container( diff --git a/lib/pages/main/view.dart b/lib/pages/main/view.dart index 651e8b91e..006ad3872 100644 --- a/lib/pages/main/view.dart +++ b/lib/pages/main/view.dart @@ -143,7 +143,7 @@ class _MainAppState extends State } }, child: LayoutBuilder( - builder: (_, constriants) { + builder: (context, constriants) { bool isPortait = constriants.maxHeight > constriants.maxWidth; return Scaffold( diff --git a/lib/pages/member/new/content/member_contribute/content/article/member_article.dart b/lib/pages/member/new/content/member_contribute/content/article/member_article.dart index 263f8f2ea..3f131fb4a 100644 --- a/lib/pages/member/new/content/member_contribute/content/article/member_article.dart +++ b/lib/pages/member/new/content/member_contribute/content/article/member_article.dart @@ -52,7 +52,7 @@ class _MemberArticleState extends State }, child: ListView.separated( itemCount: loadingState.response.length, - itemBuilder: (_, index) { + itemBuilder: (context, index) { if (index == loadingState.response.length - 1) { _controller.onLoadMore(); } @@ -66,7 +66,7 @@ class _MemberArticleState extends State ? Container( margin: const EdgeInsets.symmetric(vertical: 6), child: LayoutBuilder( - builder: (_, constraints) { + builder: (context, constraints) { return NetworkImgLayer( radius: 6, src: item.originImageUrls!.first, @@ -97,7 +97,7 @@ class _MemberArticleState extends State : null, ); }, - separatorBuilder: (_, index) => Divider(height: 1), + separatorBuilder: (context, index) => Divider(height: 1), ), ), ) diff --git a/lib/pages/member/new/content/member_contribute/content/favorite/member_favorite.dart b/lib/pages/member/new/content/member_contribute/content/favorite/member_favorite.dart index bff1c4d0c..9a6319b67 100644 --- a/lib/pages/member/new/content/member_contribute/content/favorite/member_favorite.dart +++ b/lib/pages/member/new/content/member_contribute/content/favorite/member_favorite.dart @@ -168,7 +168,7 @@ class _MemberFavoriteState extends State leading: Container( margin: const EdgeInsets.symmetric(vertical: 6), child: LayoutBuilder( - builder: (_, constraints) { + builder: (context, constraints) { return Stack( children: [ NetworkImgLayer( diff --git a/lib/pages/member/new/content/member_contribute/content/favorite/member_favorite_ctr.dart b/lib/pages/member/new/content/member_contribute/content/favorite/member_favorite_ctr.dart index 7518b58bd..26da276eb 100644 --- a/lib/pages/member/new/content/member_contribute/content/favorite/member_favorite_ctr.dart +++ b/lib/pages/member/new/content/member_contribute/content/favorite/member_favorite_ctr.dart @@ -57,7 +57,7 @@ class MemberFavoriteCtr extends CommonController { } Future userfavFolder() async { - var res = await Request().get(Api.userFavFolder, data: { + var res = await Request().get(Api.userFavFolder, queryParameters: { 'pn': page, 'ps': 20, 'up_mid': mid, @@ -81,7 +81,7 @@ class MemberFavoriteCtr extends CommonController { } Future userSubFolder() async { - var res = await Request().get(Api.userSubFolder, data: { + var res = await Request().get(Api.userSubFolder, queryParameters: { 'up_mid': mid, 'ps': 20, 'pn': page1, diff --git a/lib/pages/member/new/content/member_dynamic/member_dynamic.dart b/lib/pages/member/new/content/member_dynamic/member_dynamic.dart index adbfd557f..483b3cdae 100644 --- a/lib/pages/member/new/content/member_dynamic/member_dynamic.dart +++ b/lib/pages/member/new/content/member_dynamic/member_dynamic.dart @@ -41,7 +41,7 @@ class _MemberDynamicState extends State }, child: ListView.separated( itemCount: loadingState.response.length, - itemBuilder: (_, index) { + itemBuilder: (context, index) { if (index == loadingState.response.length - 1) { _controller.onLoadMore(); } @@ -49,7 +49,8 @@ class _MemberDynamicState extends State item: loadingState.response[index], ); }, - separatorBuilder: (_, index) => const SizedBox(height: 10), + separatorBuilder: (context, index) => + const SizedBox(height: 10), ), ) : errorWidget( diff --git a/lib/pages/member/new/content/member_home/member_home.dart b/lib/pages/member/new/content/member_home/member_home.dart index ed05ff1a6..d2a120d04 100644 --- a/lib/pages/member/new/content/member_home/member_home.dart +++ b/lib/pages/member/new/content/member_home/member_home.dart @@ -128,7 +128,7 @@ class _MemberHomeState extends State ? Container( margin: const EdgeInsets.symmetric(vertical: 6), child: LayoutBuilder( - builder: (_, constraints) { + builder: (context, constraints) { return NetworkImgLayer( radius: 6, src: loadingState.response.article.item diff --git a/lib/pages/member/new/member_page.dart b/lib/pages/member/new/member_page.dart index eb6aa8ed5..8cffc79b4 100644 --- a/lib/pages/member/new/member_page.dart +++ b/lib/pages/member/new/member_page.dart @@ -54,7 +54,7 @@ class _MemberPageNewState extends State body: Obx( () => _userController.loadingState.value is Success ? LayoutBuilder( - builder: (_, constraints) { + builder: (context, constraints) { // if (constraints.maxHeight > constraints.maxWidth) { return ExtendedNestedScrollView( key: _key, @@ -251,7 +251,7 @@ class _MemberPageNewState extends State onTap: () { showDialog( context: context, - builder: (_) => AlertDialog( + builder: (context) => AlertDialog( clipBehavior: Clip.hardEdge, contentPadding: const EdgeInsets.symmetric( horizontal: 20, diff --git a/lib/pages/member/new/widget/edit_profile_page.dart b/lib/pages/member/new/widget/edit_profile_page.dart index 8b7293376..eef997a81 100644 --- a/lib/pages/member/new/widget/edit_profile_page.dart +++ b/lib/pages/member/new/widget/edit_profile_page.dart @@ -75,7 +75,7 @@ class _EditProfilePageState extends State { Request() .get( '${HttpString.appBaseUrl}/x/v2/account/myinfo', - data: data, + queryParameters: data, ) .then((data) { setState(() { @@ -145,7 +145,8 @@ class _EditProfilePageState extends State { onTap: () { showDialog( context: context, - builder: (_) => _sexDialog(loadingState.response['sex']), + builder: (context_) => + _sexDialog(loadingState.response['sex']), ); }, ), diff --git a/lib/pages/member/view.dart b/lib/pages/member/view.dart index ae22db1bf..bda0f5c99 100644 --- a/lib/pages/member/view.dart +++ b/lib/pages/member/view.dart @@ -152,7 +152,7 @@ class _MemberPageState extends State onTap: () { showDialog( context: context, - builder: (_) => AlertDialog( + builder: (context) => AlertDialog( clipBehavior: Clip.hardEdge, contentPadding: const EdgeInsets.symmetric( horizontal: 20, diff --git a/lib/pages/member_search/search_dynamic.dart b/lib/pages/member_search/search_dynamic.dart index 76c736ea7..23e749c48 100644 --- a/lib/pages/member_search/search_dynamic.dart +++ b/lib/pages/member_search/search_dynamic.dart @@ -277,7 +277,7 @@ class SearchDynamic extends StatelessWidget { ? Container( margin: const EdgeInsets.symmetric(vertical: 6), child: LayoutBuilder( - builder: (_, constraints) { + builder: (context, constraints) { return NetworkImgLayer( radius: 6, src: json['pic'], diff --git a/lib/pages/msg_feed_top/at_me/view.dart b/lib/pages/msg_feed_top/at_me/view.dart index 3b91722ab..a8304a4e9 100644 --- a/lib/pages/msg_feed_top/at_me/view.dart +++ b/lib/pages/msg_feed_top/at_me/view.dart @@ -67,7 +67,7 @@ class _AtMePageState extends State { itemCount: _atMeController.msgFeedAtMeList.length, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemBuilder: (_, int i) { + itemBuilder: (context, int i) { return ListTile( onTap: () { String? nativeUri = diff --git a/lib/pages/msg_feed_top/like_me/view.dart b/lib/pages/msg_feed_top/like_me/view.dart index 8b98bb388..38cf253ae 100644 --- a/lib/pages/msg_feed_top/like_me/view.dart +++ b/lib/pages/msg_feed_top/like_me/view.dart @@ -117,7 +117,7 @@ class LikeMeList extends StatelessWidget { itemCount: msgFeedLikeMeList.length, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemBuilder: (_, int i) { + itemBuilder: (context, int i) { return ListTile( onTap: () { String? nativeUri = msgFeedLikeMeList[i].item?.nativeUri; diff --git a/lib/pages/msg_feed_top/reply_me/view.dart b/lib/pages/msg_feed_top/reply_me/view.dart index 8bb5134f0..97fe30828 100644 --- a/lib/pages/msg_feed_top/reply_me/view.dart +++ b/lib/pages/msg_feed_top/reply_me/view.dart @@ -66,7 +66,7 @@ class _ReplyMePageState extends State { itemCount: _replyMeController.msgFeedReplyMeList.length, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemBuilder: (_, int i) { + itemBuilder: (context, int i) { return ListTile( onTap: () { String? nativeUri = _replyMeController diff --git a/lib/pages/msg_feed_top/sys_msg/view.dart b/lib/pages/msg_feed_top/sys_msg/view.dart index b722b2169..575416999 100644 --- a/lib/pages/msg_feed_top/sys_msg/view.dart +++ b/lib/pages/msg_feed_top/sys_msg/view.dart @@ -72,7 +72,7 @@ class _SysMsgPageState extends State { itemCount: _sysMsgController.msgFeedSysMsgList.length, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemBuilder: (_, int i) { + itemBuilder: (context, int i) { String? content = _sysMsgController.msgFeedSysMsgList[i].content; if (content != null) { @@ -88,7 +88,7 @@ class _SysMsgPageState extends State { onLongPress: () { showDialog( context: context, - builder: (_) => AlertDialog( + builder: (context) => AlertDialog( title: const Text('确定删除该通知?'), actions: [ TextButton( diff --git a/lib/pages/preview/controller.dart b/lib/pages/preview/controller.dart deleted file mode 100644 index 527152e3a..000000000 --- a/lib/pages/preview/controller.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'dart:io'; - -import 'package:device_info_plus/device_info_plus.dart'; -import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; -import 'package:get/get.dart'; -import 'package:dio/dio.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:share_plus/share_plus.dart'; - -class PreviewController extends GetxController { - DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); - RxInt initialPage = 0.obs; - RxInt currentPage = 1.obs; - RxList imgList = [].obs; - bool storage = true; - bool videos = true; - bool photos = true; - String currentImgUrl = ''; - - requestPermission() async { - Map statuses = await [ - Permission.storage, - // Permission.photos - ].request(); - - statuses[Permission.storage].toString(); - // final photosInfo = statuses[Permission.photos].toString(); - } - - // 图片分享 - void onShareImg() async { - SmartDialog.showLoading(); - var response = await Dio().get(imgList[initialPage.value], - options: Options(responseType: ResponseType.bytes)); - final temp = await getTemporaryDirectory(); - SmartDialog.dismiss(); - String imgName = - "PiliPalaX_pic_${DateTime.now().toString().replaceAll(' ', '_').replaceAll(':', '-').split('.').first}.jpg"; - var path = '${temp.path}/$imgName'; - File(path).writeAsBytesSync(response.data); - Share.shareXFiles([XFile(path)], subject: imgList[initialPage.value]); - } - - void onChange(int index) { - initialPage.value = index; - currentPage.value = index + 1; - currentImgUrl = imgList[index]; - } -} diff --git a/lib/pages/preview/index.dart b/lib/pages/preview/index.dart deleted file mode 100644 index 9fb82e8dd..000000000 --- a/lib/pages/preview/index.dart +++ /dev/null @@ -1,4 +0,0 @@ -library preview; - -export './controller.dart'; -export './view.dart'; diff --git a/lib/pages/preview/view.dart b/lib/pages/preview/view.dart deleted file mode 100644 index f89692b2a..000000000 --- a/lib/pages/preview/view.dart +++ /dev/null @@ -1,328 +0,0 @@ -// ignore_for_file: library_private_types_in_public_api - -import 'dart:io'; - -import 'package:PiliPalaX/utils/extension.dart'; -import 'package:PiliPalaX/utils/storage.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; -import 'package:get/get.dart'; -import 'package:flutter/material.dart'; -import 'package:extended_image/extended_image.dart'; -import 'package:PiliPalaX/utils/download.dart'; -import 'controller.dart'; -import 'package:status_bar_control/status_bar_control.dart'; - -typedef DoubleClickAnimationListener = void Function(); - -class ImagePreview extends StatefulWidget { - final int? initialPage; - final List? imgList; - const ImagePreview({ - super.key, - this.initialPage, - this.imgList, - }); - - @override - _ImagePreviewState createState() => _ImagePreviewState(); -} - -class _ImagePreviewState extends State - with TickerProviderStateMixin { - final PreviewController _previewController = Get.put(PreviewController()); - // late AnimationController animationController; - late AnimationController _doubleClickAnimationController; - Animation? _doubleClickAnimation; - late DoubleClickAnimationListener _doubleClickAnimationListener; - List doubleTapScales = [1.0, 2.0]; - late List _imgList; - int _quality = 80; - late List _thumbList; - - @override - void initState() { - super.initState(); - - _imgList = widget.imgList?.map((url) => url.http2https).toList() ?? - (Get.arguments['imgList'] as List) - .map((url) => url.http2https) - .toList(); - _thumbList = List.generate(_imgList.length, (_) => true); - - _quality = - GStorage.setting.get(SettingBoxKey.previewQuality, defaultValue: 80); - - _previewController.initialPage.value = - widget.initialPage ?? Get.arguments['initialPage']; - _previewController.currentPage.value = - (widget.initialPage ?? Get.arguments['initialPage']) + 1; - _previewController.imgList.value = _imgList; - _previewController.currentImgUrl = - _imgList[widget.initialPage ?? Get.arguments['initialPage']]; - // animationController = AnimationController( - // vsync: this, duration: const Duration(milliseconds: 400)); - setStatusBar(); - _doubleClickAnimationController = AnimationController( - duration: const Duration(milliseconds: 250), vsync: this); - } - - onOpenMenu() { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - clipBehavior: Clip.hardEdge, - contentPadding: const EdgeInsets.fromLTRB(0, 12, 0, 12), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - onTap: () { - _previewController.onShareImg(); - Get.back(); - }, - dense: true, - title: const Text('分享', style: TextStyle(fontSize: 14)), - ), - ListTile( - onTap: () { - Clipboard.setData( - ClipboardData(text: _previewController.currentImgUrl)) - .then((value) { - Get.back(); - SmartDialog.showToast('已复制到粘贴板'); - }).catchError((err) { - SmartDialog.showNotify( - msg: err.toString(), - notifyType: NotifyType.error, - ); - }); - }, - dense: true, - title: const Text('复制链接', style: TextStyle(fontSize: 14)), - ), - ListTile( - onTap: () { - Get.back(); - DownloadUtils.downloadImg( - context, - [_previewController.currentImgUrl], - ); - }, - dense: true, - title: const Text('保存到手机', style: TextStyle(fontSize: 14)), - ), - if (_imgList.length > 1) - ListTile( - onTap: () { - Get.back(); - DownloadUtils.downloadImg( - context, - _imgList, - ); - }, - dense: true, - title: const Text('保存全部到手机', style: TextStyle(fontSize: 14)), - ), - ], - ), - ); - }, - ); - } - - // 隐藏状态栏,避免遮挡图片内容 - setStatusBar() async { - if (Platform.isIOS || Platform.isAndroid) { - await StatusBarControl.setHidden(true, - animation: StatusBarAnimation.SLIDE); - } - } - - @override - void dispose() { - // animationController.dispose(); - try { - StatusBarControl.setHidden(false, animation: StatusBarAnimation.SLIDE); - } catch (_) {} - _doubleClickAnimationController.dispose(); - clearGestureDetailsCache(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - primary: false, - extendBody: true, - appBar: AppBar( - primary: false, - toolbarHeight: 0, - backgroundColor: Colors.black, - systemOverlayStyle: SystemUiOverlayStyle.dark, - ), - body: Stack( - children: [ - Semantics( - label: '长按保存', - child: GestureDetector( - onLongPress: () => onOpenMenu(), - child: ExtendedImageGesturePageView.builder( - controller: ExtendedPageController( - initialPage: _previewController.initialPage.value, - pageSpacing: 0, - ), - onPageChanged: (int index) => - _previewController.onChange(index), - canScrollPage: (GestureDetails? gestureDetails) => - gestureDetails?.totalScale == null || - gestureDetails!.totalScale! <= 1.0, - itemCount: _imgList.length, - itemBuilder: (BuildContext context, int index) { - return ExtendedImage.network( - '${_imgList[index]}${_thumbList[index] && _quality != 100 ? '@${_quality}q.webp' : ''}', - fit: BoxFit.contain, - mode: ExtendedImageMode.gesture, - handleLoadingProgress: true, - clearMemoryCacheWhenDispose: true, - onDoubleTap: (ExtendedImageGestureState state) { - final Offset? pointerDownPosition = - state.pointerDownPosition; - final double? begin = state.gestureDetails!.totalScale; - double end; - - //remove old - _doubleClickAnimation - ?.removeListener(_doubleClickAnimationListener); - - //stop pre - _doubleClickAnimationController.stop(); - - //reset to use - _doubleClickAnimationController.reset(); - - if (begin == doubleTapScales[0]) { - setState(() {}); - end = doubleTapScales[1]; - } else { - setState(() {}); - end = doubleTapScales[0]; - } - - _doubleClickAnimationListener = () { - state.handleDoubleTap( - scale: _doubleClickAnimation!.value, - doubleTapPosition: pointerDownPosition); - }; - _doubleClickAnimation = _doubleClickAnimationController - .drive(Tween(begin: begin, end: end)); - - _doubleClickAnimation! - .addListener(_doubleClickAnimationListener); - - _doubleClickAnimationController.forward(); - }, - // ignore: body_might_complete_normally_nullable - loadStateChanged: (ExtendedImageState state) { - if (state.extendedImageLoadState == LoadState.loading) { - final ImageChunkEvent? loadingProgress = - state.loadingProgress; - final double? progress = - loadingProgress?.expectedTotalBytes != null - ? loadingProgress!.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null; - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: 150.0, - child: LinearProgressIndicator( - value: progress ?? 0), - ), - // const SizedBox(height: 10.0), - // Text('${((progress ?? 0.0) * 100).toInt()}%',), - ], - ), - ); - } else if (state.extendedImageLoadState == - LoadState.failed) { - WidgetsBinding.instance - .addPostFrameCallback((timeStamp) { - setState(() { - _thumbList[index] = false; - }); - }); - } - }, - initGestureConfigHandler: (ExtendedImageState state) { - return GestureConfig( - inPageView: true, - initialScale: 1.0, - maxScale: 5.0, - animationMaxScale: 6.0, - initialAlignment: InitialAlignment.center, - ); - }, - ); - }, - ), - ), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: Container( - padding: EdgeInsets.only( - left: 20, - right: 20, - bottom: MediaQuery.of(context).padding.bottom + 30), - // decoration: const BoxDecoration( - // gradient: LinearGradient( - // begin: Alignment.topCenter, - // end: Alignment.bottomCenter, - // colors: [ - // Colors.transparent, - // Colors.black87, - // ], - // tileMode: TileMode.mirror, - // ), - // ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _imgList.length > 1 - ? Obx( - () => Text.rich( - textAlign: TextAlign.center, - TextSpan( - style: const TextStyle( - color: Colors.white, fontSize: 16), - children: [ - TextSpan( - text: _previewController.currentPage - .toString()), - const TextSpan(text: ' / '), - TextSpan(text: _imgList.length.toString()), - ]), - ), - ) - : const SizedBox(), - IconButton( - onPressed: () => Get.back(), - icon: const Icon(Icons.close, color: Colors.white), - tooltip: '关闭', - ), - ], - )), - ), - ], - ), - ); - } -} diff --git a/lib/pages/search/view.dart b/lib/pages/search/view.dart index a86ea71db..268401e42 100644 --- a/lib/pages/search/view.dart +++ b/lib/pages/search/view.dart @@ -217,7 +217,7 @@ class _SearchPageState extends State with RouteAware { return switch (loadingState) { Success() => (loadingState.response as List?)?.isNotEmpty == true ? LayoutBuilder( - builder: (_, constraints) => HotKeyword( + builder: (context, constraints) => HotKeyword( width: constraints.maxWidth, hotSearchList: loadingState.response, onClick: _searchController.onClickKeyword, diff --git a/lib/pages/search_panel/widgets/article_panel.dart b/lib/pages/search_panel/widgets/article_panel.dart index c6f743abb..fd49d97b5 100644 --- a/lib/pages/search_panel/widgets/article_panel.dart +++ b/lib/pages/search_panel/widgets/article_panel.dart @@ -255,7 +255,7 @@ class ArticlePanelController extends GetxController { showModalBottomSheet( context: context, isScrollControlled: true, - builder: (_) => SingleChildScrollView( + builder: (context) => SingleChildScrollView( child: Container( width: double.infinity, padding: EdgeInsets.only( diff --git a/lib/pages/search_panel/widgets/user_panel.dart b/lib/pages/search_panel/widgets/user_panel.dart index 1d7df53c2..4618a4e65 100644 --- a/lib/pages/search_panel/widgets/user_panel.dart +++ b/lib/pages/search_panel/widgets/user_panel.dart @@ -188,7 +188,7 @@ class UserPanelController extends GetxController { showModalBottomSheet( context: context, isScrollControlled: true, - builder: (_) => SingleChildScrollView( + builder: (context) => SingleChildScrollView( child: Container( width: double.infinity, padding: EdgeInsets.only( diff --git a/lib/pages/search_panel/widgets/video_panel.dart b/lib/pages/search_panel/widgets/video_panel.dart index ed3b286a2..1a433778a 100644 --- a/lib/pages/search_panel/widgets/video_panel.dart +++ b/lib/pages/search_panel/widgets/video_panel.dart @@ -256,7 +256,7 @@ class VideoPanelController extends GetxController { showModalBottomSheet( context: context, isScrollControlled: true, - builder: (_) => StatefulBuilder( + builder: (context) => StatefulBuilder( builder: (context, setState) { Widget dateWidget([bool isFirst = true]) { return SearchText( diff --git a/lib/pages/setting/pages/display_mode.dart b/lib/pages/setting/pages/display_mode.dart index 7cb1951f2..95fd99f00 100644 --- a/lib/pages/setting/pages/display_mode.dart +++ b/lib/pages/setting/pages/display_mode.dart @@ -89,7 +89,7 @@ class _SetDisplayModeState extends State { Expanded( child: ListView.builder( itemCount: modes.length, - itemBuilder: (_, int i) { + itemBuilder: (context, int i) { final DisplayMode mode = modes[i]; return RadioListTile( value: mode, diff --git a/lib/pages/setting/sponsor_block_page.dart b/lib/pages/setting/sponsor_block_page.dart index 0ab54802b..13aa0b065 100644 --- a/lib/pages/setting/sponsor_block_page.dart +++ b/lib/pages/setting/sponsor_block_page.dart @@ -390,13 +390,13 @@ class _SponsorBlockPageState extends State { _dividerL, SliverList.separated( itemCount: _blockSettings.length, - itemBuilder: (_, index) => ListTile( + itemBuilder: (context, index) => ListTile( dense: true, enabled: _blockSettings[index].second != SkipType.disable, onTap: () { showDialog( context: context, - builder: (_) => AlertDialog( + builder: (context) => AlertDialog( clipBehavior: Clip.hardEdge, contentPadding: const EdgeInsets.symmetric(vertical: 16), title: Text.rich( @@ -527,7 +527,7 @@ class _SponsorBlockPageState extends State { ), ), ), - separatorBuilder: (_, index) => Divider( + separatorBuilder: (context, index) => Divider( height: 1, color: Theme.of(context).colorScheme.outline.withOpacity(0.1), ), diff --git a/lib/pages/video/detail/controller.dart b/lib/pages/video/detail/controller.dart index 39d028ec8..551fc65dd 100644 --- a/lib/pages/video/detail/controller.dart +++ b/lib/pages/video/detail/controller.dart @@ -216,6 +216,16 @@ class VideoDetailController extends GetxController PersistentBottomSheetController? bsController; + bool imageStatus = false; + + void onViewImage() { + imageStatus = true; + } + + void onDismissed(value) { + imageStatus = false; + } + @override void onInit() { super.onInit(); @@ -311,7 +321,7 @@ class VideoDetailController extends GetxController void _showCategoryDialog(BuildContext context, SegmentModel segment) { showDialog( context: context, - builder: (_) => AlertDialog( + builder: (context) => AlertDialog( clipBehavior: Clip.hardEdge, contentPadding: const EdgeInsets.fromLTRB(0, 10, 0, 10), content: SingleChildScrollView( @@ -376,7 +386,7 @@ class VideoDetailController extends GetxController void _showVoteDialog(BuildContext context, SegmentModel segment) { showDialog( context: context, - builder: (_) => AlertDialog( + builder: (context) => AlertDialog( clipBehavior: Clip.hardEdge, contentPadding: const EdgeInsets.fromLTRB(0, 10, 0, 10), content: SingleChildScrollView( @@ -426,7 +436,7 @@ class VideoDetailController extends GetxController void showSBDetail(BuildContext context) { showDialog( context: context, - builder: (_) => AlertDialog( + builder: (context) => AlertDialog( clipBehavior: Clip.hardEdge, contentPadding: const EdgeInsets.fromLTRB(0, 10, 0, 10), content: SingleChildScrollView( @@ -532,7 +542,6 @@ class VideoDetailController extends GetxController void _showBlockToast(String msg) { SmartDialog.showToast( msg, - displayType: SmartToastType.normal, alignment: plPlayerController.isFullScreen.value ? Alignment(-0.9, 0.5) : null, ); @@ -553,7 +562,7 @@ class VideoDetailController extends GetxController Future _querySponsorBlock() async { dynamic result = await Request().get( '${GStorage.blockServer}/api/skipSegments', - data: { + queryParameters: { 'videoID': bvid, 'cid': cid.value, }, @@ -1112,7 +1121,7 @@ class VideoDetailController extends GetxController onPressed: () { showDialog( context: context, - builder: (_) { + builder: (context) { String initV = value; return AlertDialog( content: TextFormField( diff --git a/lib/pages/video/detail/introduction/controller.dart b/lib/pages/video/detail/introduction/controller.dart index 324cafffa..e19bb6745 100644 --- a/lib/pages/video/detail/introduction/controller.dart +++ b/lib/pages/video/detail/introduction/controller.dart @@ -849,7 +849,7 @@ class _PayCoinsPageState extends State @override Widget build(BuildContext context) { - return LayoutBuilder(builder: (_, constraints) { + return LayoutBuilder(builder: (context, constraints) { return _buildBody(constraints.maxHeight > constraints.maxWidth); }); } diff --git a/lib/pages/video/detail/introduction/widgets/create_fav_page.dart b/lib/pages/video/detail/introduction/widgets/create_fav_page.dart index fb720991f..10837016b 100644 --- a/lib/pages/video/detail/introduction/widgets/create_fav_page.dart +++ b/lib/pages/video/detail/introduction/widgets/create_fav_page.dart @@ -230,7 +230,7 @@ class _CreateFavPageState extends State { Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: LayoutBuilder( - builder: (_, constraints) { + builder: (context, constraints) { return ClipRRect( borderRadius: BorderRadius.circular(6), child: Image.network( diff --git a/lib/pages/video/detail/reply/controller.dart b/lib/pages/video/detail/reply/controller.dart index ca79728da..763bc2d5d 100644 --- a/lib/pages/video/detail/reply/controller.dart +++ b/lib/pages/video/detail/reply/controller.dart @@ -3,8 +3,10 @@ import 'package:PiliPalaX/http/loading_state.dart'; import 'package:PiliPalaX/models/common/reply_type.dart'; import 'package:PiliPalaX/pages/common/reply_controller.dart'; import 'package:PiliPalaX/http/reply.dart'; +import 'package:PiliPalaX/pages/video/detail/controller.dart'; import 'package:PiliPalaX/utils/global_data.dart'; import 'package:fixnum/fixnum.dart' as $fixnum; +import 'package:get/get.dart'; class VideoReplyController extends ReplyController { VideoReplyController( diff --git a/lib/pages/video/detail/reply/view.dart b/lib/pages/video/detail/reply/view.dart index c3d577464..2749f9648 100644 --- a/lib/pages/video/detail/reply/view.dart +++ b/lib/pages/video/detail/reply/view.dart @@ -21,15 +21,19 @@ class VideoReplyPanel extends StatefulWidget { final String? replyLevel; final String heroTag; final Function replyReply; + final VoidCallback? onViewImage; + final ValueChanged? onDismissed; const VideoReplyPanel({ + super.key, this.bvid, this.oid, this.rpid = 0, this.replyLevel, required this.heroTag, required this.replyReply, - super.key, + this.onViewImage, + this.onDismissed, }); @override @@ -248,6 +252,8 @@ class _VideoReplyPanelState extends State isTop: _videoReplyController.hasUpTop && index == 0, upMid: loadingState.response.subjectControl.upMid, getTag: () => heroTag, + onViewImage: widget.onViewImage, + onDismissed: widget.onDismissed, ) : ReplyItem( replyItem: loadingState.response.replies[index], @@ -263,9 +269,8 @@ class _VideoReplyPanelState extends State ); }, onDelete: _videoReplyController.onMDelete, - // isTop: _videoReplyController.hasUpTop && index == 0, - // upMid: loadingState.response.subjectControl.upMid, - // getTag: () => heroTag, + onViewImage: widget.onViewImage, + onDismissed: widget.onDismissed, ); } }, diff --git a/lib/pages/video/detail/reply/widgets/reply_item.dart b/lib/pages/video/detail/reply/widgets/reply_item.dart index d98493b50..2b35d54f7 100644 --- a/lib/pages/video/detail/reply/widgets/reply_item.dart +++ b/lib/pages/video/detail/reply/widgets/reply_item.dart @@ -32,6 +32,8 @@ class ReplyItem extends StatelessWidget { this.needDivider = true, this.onReply, this.onDelete, + this.onViewImage, + this.onDismissed, }); final ReplyItemModel? replyItem; final String? replyLevel; @@ -41,6 +43,8 @@ class ReplyItem extends StatelessWidget { final bool needDivider; final Function()? onReply; final Function(dynamic rpid, dynamic frpid)? onDelete; + final VoidCallback? onViewImage; + final ValueChanged? onDismissed; @override Widget build(BuildContext context) { @@ -310,7 +314,7 @@ class ReplyItem extends StatelessWidget { showReplyRow!) ...[ Padding( padding: const EdgeInsets.only(top: 5, bottom: 12), - child: ReplyItemRow( + child: replyItemRow( replies: replyItem!.replies, replyControl: replyItem!.replyControl, // f_rpid: replyItem!.rpid, @@ -386,28 +390,15 @@ class ReplyItem extends StatelessWidget { ], ); } -} -// ignore: must_be_immutable -class ReplyItemRow extends StatelessWidget { - ReplyItemRow({ - super.key, - this.replies, - this.replyControl, - // this.f_rpid, - this.replyItem, - this.replyReply, - this.onDelete, - }); - final List? replies; - ReplyControl? replyControl; - // int? f_rpid; - ReplyItemModel? replyItem; - Function? replyReply; - final Function(dynamic rpid)? onDelete; - - @override - Widget build(BuildContext context) { + Widget replyItemRow({ + context, + replies, + replyControl, + replyItem, + replyReply, + onDelete, + }) { final int extraRow = replyControl?.isShow == true || (replyControl?.entryText != null && replies!.isEmpty) ? 1 @@ -564,294 +555,127 @@ class ReplyItemRow extends StatelessWidget { ), ); } -} -InlineSpan buildContent( - BuildContext context, - replyItem, - replyReply, - fReplyItem, - textPainter, - didExceedMaxLines, -) { - final String routePath = Get.currentRoute; - bool isVideoPage = routePath.startsWith('/video'); + InlineSpan buildContent( + BuildContext context, + replyItem, + replyReply, + fReplyItem, + textPainter, + didExceedMaxLines, + ) { + final String routePath = Get.currentRoute; + bool isVideoPage = routePath.startsWith('/video'); - // replyItem 当前回复内容 - // replyReply 查看二楼回复(回复详情)回调 - // fReplyItem 父级回复内容,用作二楼回复(回复详情)展示 - final content = replyItem.content; - String message = content.message ?? ''; - final List spanChildren = []; + // replyItem 当前回复内容 + // replyReply 查看二楼回复(回复详情)回调 + // fReplyItem 父级回复内容,用作二楼回复(回复详情)展示 + final content = replyItem.content; + String message = content.message ?? ''; + final List spanChildren = []; - if (didExceedMaxLines == true) { - final textSize = textPainter.size; - var position = textPainter.getPositionForOffset( - Offset( - textSize.width, - textSize.height, - ), - ); - final endOffset = textPainter.getOffsetBefore(position.offset); - message = message.substring(0, endOffset); - } - - // 投票 - if (content.vote.isNotEmpty) { - message.splitMapJoin(RegExp(r"\{vote:\d+?\}"), onMatch: (Match match) { - // String matchStr = match[0]!; - spanChildren.add( - TextSpan( - text: '投票: ${content.vote['title']}', - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => Get.toNamed( - '/webviewnew', - parameters: { - 'url': content.vote['url'], - 'type': 'vote', - 'pageTitle': content.vote['title'], - }, - ), + if (didExceedMaxLines == true) { + final textSize = textPainter.size; + var position = textPainter.getPositionForOffset( + Offset( + textSize.width, + textSize.height, ), ); - return ''; - }, onNonMatch: (String str) { - return str; - }); - message = message.replaceAll(RegExp(r"\{vote:\d+?\}"), ""); - } - message = message - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll(''', "'") - .replaceAll(' ', ' '); - // 构建正则表达式 - final List specialTokens = [ - ...content.emote.keys, - ...content.topicsMeta?.keys?.map((e) => '#$e#') ?? [], - ...content.atNameToMid.keys.map((e) => '@$e'), - ]; - List jumpUrlKeysList = content.jumpUrl.keys.map((String e) { - return e.replaceAllMapped( - RegExp(r'[?+*]'), (match) => '\\${match.group(0)}'); - }).toList(); - specialTokens.sort((a, b) => b.length.compareTo(a.length)); - String patternStr = specialTokens.map(RegExp.escape).join('|'); - if (patternStr.isNotEmpty) { - patternStr += "|"; - } - patternStr += r'(\b(?:\d+[::])?[0-5]?[0-9][::][0-5]?[0-9]\b)'; - if (jumpUrlKeysList.isNotEmpty) { - patternStr += '|${jumpUrlKeysList.map(RegExp.escape).join('|')}'; - } - final RegExp pattern = RegExp(patternStr); - List matchedStrs = []; - void addPlainTextSpan(str) { - spanChildren.add(TextSpan( - text: str, - )); - // TextSpan( - // - // text: str, - // recognizer: TapGestureRecognizer() - // ..onTap = () => replyReply - // ?.call(replyItem.root == 0 ? replyItem : fReplyItem))))); - } + final endOffset = textPainter.getOffsetBefore(position.offset); + message = message.substring(0, endOffset); + } - // 分割文本并处理每个部分 - message.splitMapJoin( - pattern, - onMatch: (Match match) { - String matchStr = match[0]!; - if (content.emote.containsKey(matchStr)) { - // 处理表情 - final int size = content.emote[matchStr]['meta']['size']; - spanChildren.add(WidgetSpan( - child: ExcludeSemantics( - child: NetworkImgLayer( - src: content.emote[matchStr]['url'], - type: 'emote', - width: size * 20, - height: size * 20, - semanticsLabel: matchStr, - )), - )); - } else if (matchStr.startsWith("@") && - content.atNameToMid.containsKey(matchStr.substring(1))) { - // 处理@用户 - final String userName = matchStr.substring(1); - final int userId = content.atNameToMid[userName]; + // 投票 + if (content.vote.isNotEmpty) { + message.splitMapJoin(RegExp(r"\{vote:\d+?\}"), onMatch: (Match match) { + // String matchStr = match[0]!; spanChildren.add( TextSpan( - text: matchStr, + text: '投票: ${content.vote['title']}', style: TextStyle( color: Theme.of(context).colorScheme.primary, ), recognizer: TapGestureRecognizer() - ..onTap = () { - final String heroTag = Utils.makeHeroTag(userId); - Get.toNamed( - '/member?mid=$userId', - arguments: {'face': '', 'heroTag': heroTag}, - ); - }, - ), - ); - } else if (RegExp(r'^\b(?:\d+[::])?[0-5]?[0-9][::][0-5]?[0-9]\b$') - .hasMatch(matchStr)) { - matchStr = matchStr.replaceAll(':', ':'); - spanChildren.add( - TextSpan( - text: ' $matchStr ', - style: isVideoPage - ? TextStyle( - color: Theme.of(context).colorScheme.primary, - ) - : null, - recognizer: TapGestureRecognizer() - ..onTap = () { - // 跳转到指定位置 - if (isVideoPage) { - try { - SmartDialog.showToast('跳转至:$matchStr'); - Get.find( - tag: Get.arguments['heroTag']) - .plPlayerController - .seekTo(Duration(seconds: Utils.duration(matchStr)), - type: 'slider'); - } catch (e) { - SmartDialog.showToast('跳转失败: $e'); - } - } - }, - ), - ); - } else { - String appUrlSchema = ''; - final bool enableWordRe = setting.get(SettingBoxKey.enableWordRe, - defaultValue: false) as bool; - if (content.jumpUrl[matchStr] != null && - !matchedStrs.contains(matchStr)) { - appUrlSchema = content.jumpUrl[matchStr]['app_url_schema']; - if (appUrlSchema.startsWith('bilibili://search') && !enableWordRe) { - addPlainTextSpan(matchStr); - return ""; - } - spanChildren.addAll( - [ - if (content.jumpUrl[matchStr]?['prefix_icon'] != null) ...[ - WidgetSpan( - child: Image.network( - content.jumpUrl[matchStr]['prefix_icon'], - height: 19, - color: Theme.of(context).colorScheme.primary, + ..onTap = () => Get.toNamed( + '/webviewnew', + parameters: { + 'url': content.vote['url'], + 'type': 'vote', + 'pageTitle': content.vote['title'], + }, ), - ) - ], - TextSpan( - text: content.jumpUrl[matchStr]['title'], - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - ), - recognizer: TapGestureRecognizer() - ..onTap = () async { - final String title = content.jumpUrl[matchStr]['title']; - if (appUrlSchema == '') { - if (matchStr.startsWith('BV')) { - UrlUtils.matchUrlPush( - matchStr, - title, - '', - ); - } else if (RegExp(r'^[Cc][Vv][0-9]+$') - .hasMatch(matchStr)) { - Get.toNamed('/htmlRender', parameters: { - 'url': 'https://www.bilibili.com/read/$matchStr', - 'title': title, - 'id': matchStr, - 'dynamicType': 'read' - }); - } else { - final String redirectUrl = - await UrlUtils.parseRedirectUrl(matchStr); - // if (redirectUrl == matchStr) { - // Clipboard.setData(ClipboardData(text: matchStr)); - // SmartDialog.showToast('地址可能有误'); - // return; - // } - Uri uri = Uri.parse(redirectUrl); - PiliScheme.routePush(uri); - // final String pathSegment = Uri.parse(redirectUrl).path; - // final String lastPathSegment = - // pathSegment.split('/').last; - // if (lastPathSegment.startsWith('BV')) { - // UrlUtils.matchUrlPush( - // lastPathSegment, - // title, - // redirectUrl, - // ); - // } else { - // Get.toNamed( - // '/webviewnew', - // parameters: { - // 'url': redirectUrl, - // 'type': 'url', - // 'pageTitle': title - // }, - // ); - // } - } - } else { - if (appUrlSchema.startsWith('bilibili://search')) { - Get.toNamed('/searchResult', - parameters: {'keyword': title}); - } else if (matchStr.startsWith('https://b23.tv')) { - final String redirectUrl = - await UrlUtils.parseRedirectUrl(matchStr); - final String pathSegment = Uri.parse(redirectUrl).path; - final String lastPathSegment = - pathSegment.split('/').last; - if (lastPathSegment.startsWith('BV')) { - UrlUtils.matchUrlPush( - lastPathSegment, - title, - redirectUrl, - ); - } else { - Get.toNamed( - '/webviewnew', - parameters: { - 'url': redirectUrl, - 'type': 'url', - 'pageTitle': title - }, - ); - } - } else { - Get.toNamed( - '/webviewnew', - parameters: { - 'url': matchStr, - 'type': 'url', - 'pageTitle': title - }, - ); - } - } - }, - ) - ], - ); - // 只显示一次 - matchedStrs.add(matchStr); - } else if (matchStr.length > 1 && - content.topicsMeta[matchStr.substring(1, matchStr.length - 1)] != - null) { + ), + ); + return ''; + }, onNonMatch: (String str) { + return str; + }); + message = message.replaceAll(RegExp(r"\{vote:\d+?\}"), ""); + } + message = message + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(' ', ' '); + // 构建正则表达式 + final List specialTokens = [ + ...content.emote.keys, + ...content.topicsMeta?.keys?.map((e) => '#$e#') ?? [], + ...content.atNameToMid.keys.map((e) => '@$e'), + ]; + List jumpUrlKeysList = content.jumpUrl.keys.map((String e) { + return e.replaceAllMapped( + RegExp(r'[?+*]'), (match) => '\\${match.group(0)}'); + }).toList(); + specialTokens.sort((a, b) => b.length.compareTo(a.length)); + String patternStr = specialTokens.map(RegExp.escape).join('|'); + if (patternStr.isNotEmpty) { + patternStr += "|"; + } + patternStr += r'(\b(?:\d+[::])?[0-5]?[0-9][::][0-5]?[0-9]\b)'; + if (jumpUrlKeysList.isNotEmpty) { + patternStr += '|${jumpUrlKeysList.map(RegExp.escape).join('|')}'; + } + final RegExp pattern = RegExp(patternStr); + List matchedStrs = []; + void addPlainTextSpan(str) { + spanChildren.add(TextSpan( + text: str, + )); + // TextSpan( + // + // text: str, + // recognizer: TapGestureRecognizer() + // ..onTap = () => replyReply + // ?.call(replyItem.root == 0 ? replyItem : fReplyItem))))); + } + + // 分割文本并处理每个部分 + message.splitMapJoin( + pattern, + onMatch: (Match match) { + String matchStr = match[0]!; + if (content.emote.containsKey(matchStr)) { + // 处理表情 + final int size = content.emote[matchStr]['meta']['size']; + spanChildren.add(WidgetSpan( + child: ExcludeSemantics( + child: NetworkImgLayer( + src: content.emote[matchStr]['url'], + type: 'emote', + width: size * 20, + height: size * 20, + semanticsLabel: matchStr, + )), + )); + } else if (matchStr.startsWith("@") && + content.atNameToMid.containsKey(matchStr.substring(1))) { + // 处理@用户 + final String userName = matchStr.substring(1); + final int userId = content.atNameToMid[userName]; spanChildren.add( TextSpan( text: matchStr, @@ -860,110 +684,281 @@ InlineSpan buildContent( ), recognizer: TapGestureRecognizer() ..onTap = () { - final String topic = - matchStr.substring(1, matchStr.length - 1); - Get.toNamed('/searchResult', parameters: {'keyword': topic}); + final String heroTag = Utils.makeHeroTag(userId); + Get.toNamed( + '/member?mid=$userId', + arguments: {'face': '', 'heroTag': heroTag}, + ); + }, + ), + ); + } else if (RegExp(r'^\b(?:\d+[::])?[0-5]?[0-9][::][0-5]?[0-9]\b$') + .hasMatch(matchStr)) { + matchStr = matchStr.replaceAll(':', ':'); + spanChildren.add( + TextSpan( + text: ' $matchStr ', + style: isVideoPage + ? TextStyle( + color: Theme.of(context).colorScheme.primary, + ) + : null, + recognizer: TapGestureRecognizer() + ..onTap = () { + // 跳转到指定位置 + if (isVideoPage) { + try { + SmartDialog.showToast('跳转至:$matchStr'); + Get.find( + tag: Get.arguments['heroTag']) + .plPlayerController + .seekTo(Duration(seconds: Utils.duration(matchStr)), + type: 'slider'); + } catch (e) { + SmartDialog.showToast('跳转失败: $e'); + } + } }, ), ); } else { - addPlainTextSpan(matchStr); - } - } - return ''; - }, - onNonMatch: (String nonMatchStr) { - addPlainTextSpan(nonMatchStr); - return nonMatchStr; - }, - ); - - if (content.jumpUrl.keys.isNotEmpty) { - List unmatchedItems = content.jumpUrl.keys - .toList() - .where((item) => !content.message.contains(item)) - .toList(); - if (unmatchedItems.isNotEmpty) { - for (int i = 0; i < unmatchedItems.length; i++) { - String patternStr = unmatchedItems[i]; - spanChildren.addAll( - [ - if (content.jumpUrl[patternStr]?['prefix_icon'] != null) ...[ - WidgetSpan( - child: Image.network( - content.jumpUrl[patternStr]['prefix_icon'], - height: 19, + String appUrlSchema = ''; + final bool enableWordRe = setting.get(SettingBoxKey.enableWordRe, + defaultValue: false) as bool; + if (content.jumpUrl[matchStr] != null && + !matchedStrs.contains(matchStr)) { + appUrlSchema = content.jumpUrl[matchStr]['app_url_schema']; + if (appUrlSchema.startsWith('bilibili://search') && !enableWordRe) { + addPlainTextSpan(matchStr); + return ""; + } + spanChildren.addAll( + [ + if (content.jumpUrl[matchStr]?['prefix_icon'] != null) ...[ + WidgetSpan( + child: Image.network( + content.jumpUrl[matchStr]['prefix_icon'], + height: 19, + color: Theme.of(context).colorScheme.primary, + ), + ) + ], + TextSpan( + text: content.jumpUrl[matchStr]['title'], + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = () async { + final String title = content.jumpUrl[matchStr]['title']; + if (appUrlSchema == '') { + if (matchStr.startsWith('BV')) { + UrlUtils.matchUrlPush( + matchStr, + title, + '', + ); + } else if (RegExp(r'^[Cc][Vv][0-9]+$') + .hasMatch(matchStr)) { + Get.toNamed('/htmlRender', parameters: { + 'url': 'https://www.bilibili.com/read/$matchStr', + 'title': title, + 'id': matchStr, + 'dynamicType': 'read' + }); + } else { + final String redirectUrl = + await UrlUtils.parseRedirectUrl(matchStr); + // if (redirectUrl == matchStr) { + // Clipboard.setData(ClipboardData(text: matchStr)); + // SmartDialog.showToast('地址可能有误'); + // return; + // } + Uri uri = Uri.parse(redirectUrl); + PiliScheme.routePush(uri); + // final String pathSegment = Uri.parse(redirectUrl).path; + // final String lastPathSegment = + // pathSegment.split('/').last; + // if (lastPathSegment.startsWith('BV')) { + // UrlUtils.matchUrlPush( + // lastPathSegment, + // title, + // redirectUrl, + // ); + // } else { + // Get.toNamed( + // '/webviewnew', + // parameters: { + // 'url': redirectUrl, + // 'type': 'url', + // 'pageTitle': title + // }, + // ); + // } + } + } else { + if (appUrlSchema.startsWith('bilibili://search')) { + Get.toNamed('/searchResult', + parameters: {'keyword': title}); + } else if (matchStr.startsWith('https://b23.tv')) { + final String redirectUrl = + await UrlUtils.parseRedirectUrl(matchStr); + final String pathSegment = + Uri.parse(redirectUrl).path; + final String lastPathSegment = + pathSegment.split('/').last; + if (lastPathSegment.startsWith('BV')) { + UrlUtils.matchUrlPush( + lastPathSegment, + title, + redirectUrl, + ); + } else { + Get.toNamed( + '/webviewnew', + parameters: { + 'url': redirectUrl, + 'type': 'url', + 'pageTitle': title + }, + ); + } + } else { + Get.toNamed( + '/webviewnew', + parameters: { + 'url': matchStr, + 'type': 'url', + 'pageTitle': title + }, + ); + } + } + }, + ) + ], + ); + // 只显示一次 + matchedStrs.add(matchStr); + } else if (matchStr.length > 1 && + content.topicsMeta[matchStr.substring(1, matchStr.length - 1)] != + null) { + spanChildren.add( + TextSpan( + text: matchStr, + style: TextStyle( color: Theme.of(context).colorScheme.primary, ), - ) - ], - TextSpan( - text: content.jumpUrl[patternStr]['title'], - style: TextStyle( - color: Theme.of(context).colorScheme.primary, + recognizer: TapGestureRecognizer() + ..onTap = () { + final String topic = + matchStr.substring(1, matchStr.length - 1); + Get.toNamed('/searchResult', + parameters: {'keyword': topic}); + }, ), - recognizer: TapGestureRecognizer() - ..onTap = () { - Get.toNamed( - '/webviewnew', - parameters: { - 'url': patternStr, - 'type': 'url', - 'pageTitle': content.jumpUrl[patternStr]['title'] - }, - ); - }, - ) - ], - ); - } - } - } - // 图片渲染 - if (content.pictures.isNotEmpty) { - spanChildren.add(const TextSpan(text: '\n')); - spanChildren.add( - WidgetSpan( - child: LayoutBuilder( - builder: (_, constraints) => image( - constraints.maxWidth, - (content.pictures as List) - .map( - (item) => ImageModel( - width: item['img_width'], - height: item['img_height'], - url: item['img_src'], + ); + } else { + addPlainTextSpan(matchStr); + } + } + return ''; + }, + onNonMatch: (String nonMatchStr) { + addPlainTextSpan(nonMatchStr); + return nonMatchStr; + }, + ); + + if (content.jumpUrl.keys.isNotEmpty) { + List unmatchedItems = content.jumpUrl.keys + .toList() + .where((item) => !content.message.contains(item)) + .toList(); + if (unmatchedItems.isNotEmpty) { + for (int i = 0; i < unmatchedItems.length; i++) { + String patternStr = unmatchedItems[i]; + spanChildren.addAll( + [ + if (content.jumpUrl[patternStr]?['prefix_icon'] != null) ...[ + WidgetSpan( + child: Image.network( + content.jumpUrl[patternStr]['prefix_icon'], + height: 19, + color: Theme.of(context).colorScheme.primary, ), ) - .toList(), + ], + TextSpan( + text: content.jumpUrl[patternStr]['title'], + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + Get.toNamed( + '/webviewnew', + parameters: { + 'url': patternStr, + 'type': 'url', + 'pageTitle': content.jumpUrl[patternStr]['title'] + }, + ); + }, + ) + ], + ); + } + } + } + // 图片渲染 + if (content.pictures.isNotEmpty) { + spanChildren.add(const TextSpan(text: '\n')); + spanChildren.add( + WidgetSpan( + child: LayoutBuilder( + builder: (context, constraints) => imageview( + constraints.maxWidth, + (content.pictures as List) + .map( + (item) => ImageModel( + width: item['img_width'], + height: item['img_height'], + url: item['img_src'], + ), + ) + .toList(), + onViewImage, + onDismissed, + ), ), ), - ), - ); - } + ); + } - // 笔记链接 - if (content.richText.isNotEmpty) { - spanChildren.add( - TextSpan( - text: ' 笔记', - style: TextStyle( - color: Theme.of(context).colorScheme.primary, + // 笔记链接 + if (content.richText.isNotEmpty) { + spanChildren.add( + TextSpan( + text: ' 笔记', + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => Get.toNamed( + '/webviewnew', + parameters: { + 'url': content.richText['note']['click_url'], + 'type': 'note', + 'pageTitle': '笔记预览' + }, + ), ), - recognizer: TapGestureRecognizer() - ..onTap = () => Get.toNamed( - '/webviewnew', - parameters: { - 'url': content.richText['note']['click_url'], - 'type': 'note', - 'pageTitle': '笔记预览' - }, - ), - ), - ); + ); + } + // spanChildren.add(TextSpan(text: matchMember)); + return TextSpan(children: spanChildren); } - // spanChildren.add(TextSpan(text: matchMember)); - return TextSpan(children: spanChildren); } class MorePanel extends StatelessWidget { diff --git a/lib/pages/video/detail/reply/widgets/reply_item_grpc.dart b/lib/pages/video/detail/reply/widgets/reply_item_grpc.dart index 31a0c269f..6958635f7 100644 --- a/lib/pages/video/detail/reply/widgets/reply_item_grpc.dart +++ b/lib/pages/video/detail/reply/widgets/reply_item_grpc.dart @@ -35,6 +35,8 @@ class ReplyItemGrpc extends StatelessWidget { this.isTop = false, this.showDialogue, this.getTag, + this.onViewImage, + this.onDismissed, }); final ReplyInfo replyItem; final String? replyLevel; @@ -48,6 +50,8 @@ class ReplyItemGrpc extends StatelessWidget { final bool isTop; final VoidCallback? showDialogue; final Function? getTag; + final VoidCallback? onViewImage; + final ValueChanged? onDismissed; @override Widget build(BuildContext context) { @@ -63,7 +67,7 @@ class ReplyItemGrpc extends StatelessWidget { feedBack(); // showDialog( // context: Get.context!, - // builder: (_) => AlertDialog( + // builder: (context) => AlertDialog( // content: SelectableText(jsonEncode(replyItem.toProto3Json())), // ), // ); @@ -993,7 +997,7 @@ class ReplyItemGrpc extends StatelessWidget { spanChildren.add( WidgetSpan( child: LayoutBuilder( - builder: (_, constraints) => image( + builder: (context, constraints) => imageview( constraints.maxWidth, content.pictures .map( @@ -1004,6 +1008,8 @@ class ReplyItemGrpc extends StatelessWidget { ), ) .toList(), + onViewImage, + onDismissed, ), ), ), diff --git a/lib/pages/video/detail/reply_new/reply_page.dart b/lib/pages/video/detail/reply_new/reply_page.dart index eef7874ba..2a7a18908 100644 --- a/lib/pages/video/detail/reply_new/reply_page.dart +++ b/lib/pages/video/detail/reply_new/reply_page.dart @@ -127,7 +127,7 @@ class _ReplyPageState extends State child: GestureDetector( onTap: Get.back, child: LayoutBuilder( - builder: (_, constraints) { + builder: (context, constraints) { bool isH = constraints.maxWidth > constraints.maxHeight; late double padding = constraints.maxWidth * 0.12; return Padding( @@ -234,7 +234,7 @@ class _ReplyPageState extends State image: FileImage(File(_pathList[index])), ), ), - separatorBuilder: (_, index) => const SizedBox(width: 10), + separatorBuilder: (context, index) => const SizedBox(width: 10), ), ); } else { @@ -317,7 +317,7 @@ class _ReplyPageState extends State StreamBuilder( initialData: true, stream: _keyboardStream.stream, - builder: (_, snapshot) => ToolbarIconButton( + builder: (context, snapshot) => ToolbarIconButton( tooltip: '输入', onPressed: () { if (!_selectKeyboard) { @@ -334,7 +334,7 @@ class _ReplyPageState extends State StreamBuilder( initialData: true, stream: _keyboardStream.stream, - builder: (_, snapshot) => ToolbarIconButton( + builder: (context, snapshot) => ToolbarIconButton( tooltip: '表情', onPressed: () { if (_selectKeyboard) { @@ -391,7 +391,7 @@ class _ReplyPageState extends State StreamBuilder( initialData: _enablePublish, stream: _publishStream.stream, - builder: (_, snapshot) => FilledButton.tonal( + builder: (context, snapshot) => FilledButton.tonal( onPressed: snapshot.data == true ? submitReplyAdd : null, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric( diff --git a/lib/pages/video/detail/reply_new/view.dart b/lib/pages/video/detail/reply_new/view.dart index 3b5e6969a..4b62f19c2 100644 --- a/lib/pages/video/detail/reply_new/view.dart +++ b/lib/pages/video/detail/reply_new/view.dart @@ -236,7 +236,7 @@ class _VideoReplyNewDialogState extends State StreamBuilder( initialData: false, stream: _publishStream.stream, - builder: (_, snapshot) => FilledButton.tonal( + builder: (context, snapshot) => FilledButton.tonal( onPressed: snapshot.data == true ? submitReplyAdd : null, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric( diff --git a/lib/pages/video/detail/reply_reply/view.dart b/lib/pages/video/detail/reply_reply/view.dart index d0bc05b72..87ab781d2 100644 --- a/lib/pages/video/detail/reply_reply/view.dart +++ b/lib/pages/video/detail/reply_reply/view.dart @@ -20,6 +20,7 @@ import 'controller.dart'; class VideoReplyReplyPanel extends StatefulWidget { const VideoReplyReplyPanel({ + super.key, this.id, this.oid, this.rpid, @@ -29,7 +30,8 @@ class VideoReplyReplyPanel extends StatefulWidget { this.replyType, this.isDialogue = false, this.isTop = false, - super.key, + this.onViewImage, + this.onDismissed, }); final int? id; final int? oid; @@ -40,6 +42,8 @@ class VideoReplyReplyPanel extends StatefulWidget { final ReplyType? replyType; final bool isDialogue; final bool isTop; + final VoidCallback? onViewImage; + final ValueChanged? onDismissed; @override State createState() => _VideoReplyReplyPanelState(); @@ -149,7 +153,7 @@ class _VideoReplyReplyPanelState extends State { itemScrollController: _videoReplyReplyController.itemScrollCtr, physics: const AlwaysScrollableScrollPhysics(), - itemBuilder: (_, index) { + itemBuilder: (context, index) { if (widget.isDialogue) { return _buildBody( _videoReplyReplyController.loadingState.value, @@ -168,6 +172,8 @@ class _VideoReplyReplyPanelState extends State { }, upMid: _videoReplyReplyController.upMid, isTop: widget.isTop, + onViewImage: widget.onViewImage, + onDismissed: widget.onDismissed, ) : ReplyItem( replyItem: firstFloor, @@ -178,6 +184,8 @@ class _VideoReplyReplyPanelState extends State { onReply: () { _onReply(firstFloor, -1); }, + onViewImage: widget.onViewImage, + onDismissed: widget.onDismissed, ); } else if (index == 1) { return Divider( @@ -418,6 +426,8 @@ class _VideoReplyReplyPanelState extends State { ), ); }, + onViewImage: widget.onViewImage, + onDismissed: widget.onDismissed, ) : ReplyItem( replyItem: replyItem, @@ -436,6 +446,8 @@ class _VideoReplyReplyPanelState extends State { _videoReplyReplyController.loadingState.value = LoadingState.success(list); }, + onViewImage: widget.onViewImage, + onDismissed: widget.onDismissed, ); } diff --git a/lib/pages/video/detail/view.dart b/lib/pages/video/detail/view.dart index 03efd501e..ff538d960 100644 --- a/lib/pages/video/detail/view.dart +++ b/lib/pages/video/detail/view.dart @@ -346,6 +346,9 @@ class _VideoDetailPageState extends State // 离开当前页面时 void didPushNext() async { // _bufferedListener?.cancel(); + if (videoDetailController.imageStatus) { + return; + } ScreenBrightness().resetApplicationScreenBrightness(); @@ -376,6 +379,10 @@ class _VideoDetailPageState extends State @override // 返回当前页面时 void didPopNext() async { + if (videoDetailController.imageStatus) { + return; + } + isShowing = true; PlPlayerController.setPlayCallBack(playCallBack); videoIntroController.startTimer(); @@ -1286,6 +1293,8 @@ class _VideoDetailPageState extends State oid: videoDetailController.oid.value, heroTag: heroTag, replyReply: replyReply, + onViewImage: videoDetailController.onViewImage, + onDismissed: videoDetailController.onDismissed, ), ); @@ -1303,6 +1312,8 @@ class _VideoDetailPageState extends State replyType: ReplyType.video, source: 'videoDetail', isTop: isTop ?? false, + onViewImage: videoDetailController.onViewImage, + onDismissed: videoDetailController.onDismissed, ), ); }); @@ -1474,7 +1485,8 @@ class _VideoDetailPageState extends State ) : null, child: LayoutBuilder( - builder: (_, constraints) => NetworkImgLayer( + builder: (context, constraints) => + NetworkImgLayer( radius: 6, src: segment.url, width: constraints.maxHeight * diff --git a/lib/pages/video/detail/widgets/header_control.dart b/lib/pages/video/detail/widgets/header_control.dart index db2aaf3dd..85d844412 100644 --- a/lib/pages/video/detail/widgets/header_control.dart +++ b/lib/pages/video/detail/widgets/header_control.dart @@ -122,7 +122,7 @@ class _HeaderControlState extends State { elevation: 0, context: context, backgroundColor: Colors.transparent, - builder: (_) { + builder: (context) { return Container( width: double.infinity, height: 500, diff --git a/lib/pages/webview/webview_page.dart b/lib/pages/webview/webview_page.dart index adb6e1cee..71be98950 100644 --- a/lib/pages/webview/webview_page.dart +++ b/lib/pages/webview/webview_page.dart @@ -50,7 +50,7 @@ class _WebviewPageNewState extends State { title: StreamBuilder( initialData: null, stream: _titleStream.stream, - builder: (_, snapshot) => Text( + builder: (context, snapshot) => Text( maxLines: 1, snapshot.hasData ? snapshot.data! : _url, overflow: TextOverflow.ellipsis, @@ -61,7 +61,7 @@ class _WebviewPageNewState extends State { child: StreamBuilder( initialData: 0.0, stream: _progressStream.stream, - builder: (_, snapshot) => snapshot.data as double < 1 + builder: (context, snapshot) => snapshot.data as double < 1 ? LinearProgressIndicator( value: snapshot.data as double, ) diff --git a/lib/pages/whisper/view.dart b/lib/pages/whisper/view.dart index 9fa93090f..882fd3781 100644 --- a/lib/pages/whisper/view.dart +++ b/lib/pages/whisper/view.dart @@ -160,7 +160,7 @@ class _WhisperPageState extends State { itemCount: sessionList.length, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemBuilder: (_, int i) { + itemBuilder: (context, int i) { dynamic content = sessionList[i].lastMsg.content; if (content == null || content == "") { diff --git a/lib/pages/whisper_detail/view.dart b/lib/pages/whisper_detail/view.dart index da6f9d813..15ff7535a 100644 --- a/lib/pages/whisper_detail/view.dart +++ b/lib/pages/whisper_detail/view.dart @@ -159,7 +159,7 @@ class _WhisperDetailPageState extends State { shrinkWrap: true, reverse: true, itemCount: messageList.length, - itemBuilder: (_, int i) { + itemBuilder: (context, int i) { return ChatItem( item: messageList[i], eInfos: _whisperDetailController.eInfos, diff --git a/lib/router/app_pages.dart b/lib/router/app_pages.dart index 00f1cae3a..cb49697c4 100644 --- a/lib/router/app_pages.dart +++ b/lib/router/app_pages.dart @@ -72,14 +72,6 @@ class Routes { CustomGetPage(name: '/hot', page: () => const HotPage()), // 视频详情 CustomGetPage(name: '/video', page: () => const VideoDetailPage()), - // 图片预览 - // GetPage( - // name: '/preview', - // page: () => const ImagePreview(), - // transition: Transition.fade, - // transitionDuration: const Duration(milliseconds: 300), - // showCupertinoParallax: false, - // ), // CustomGetPage(name: '/webview', page: () => const WebviewPage()), CustomGetPage(name: '/webviewnew', page: () => const WebviewPageNew()), diff --git a/lib/utils/download.dart b/lib/utils/download.dart index fbe356f82..26924acaf 100644 --- a/lib/utils/download.dart +++ b/lib/utils/download.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; +import 'package:PiliPalaX/http/init.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; @@ -92,17 +93,14 @@ class DownloadUtils { if (!await checkPermissionDependOnSdkInt(context)) { return; } - Dio dio = Dio() - ..options = BaseOptions( - connectTimeout: const Duration(milliseconds: 10000), - receiveTimeout: const Duration(milliseconds: 10000), - ); for (int i = 0; i < imgList.length; i++) { SmartDialog.showLoading( msg: '正在下载原图${imgList.length > 1 ? '${i + 1}/${imgList.length}' : ''}'); - var response = await dio.get(imgList[i], - options: Options(responseType: ResponseType.bytes)); + var response = await Request().get( + imgList[i], + options: Options(responseType: ResponseType.bytes), + ); String picName = "${imgType}_${DateTime.now().toString().replaceAll(' ', '_').replaceAll(':', '-').split('.').first}"; final SaveResult result = await SaverGallery.saveImage( diff --git a/lib/utils/extension.dart b/lib/utils/extension.dart index 4ad2cd42b..ae2b05927 100644 --- a/lib/utils/extension.dart +++ b/lib/utils/extension.dart @@ -1,7 +1,7 @@ -import 'package:PiliPalaX/pages/preview/view.dart'; +import 'package:PiliPalaX/common/widgets/interactiveviewer_gallery/hero_dialog_route.dart'; +import 'package:PiliPalaX/common/widgets/interactiveviewer_gallery/interactiveviewer_gallery.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:get/get_navigation/src/dialog/dialog_route.dart'; extension ImageExtension on num { int cacheSize(BuildContext context) { @@ -67,24 +67,16 @@ extension BuildContextExt on BuildContext { void imageView({ int? initialPage, required List imgList, + ValueChanged? onDismissed, }) { Navigator.of(this).push( - GetDialogRoute( - pageBuilder: (buildContext, animation, secondaryAnimation) { - return ImagePreview( - initialPage: initialPage ?? 0, - imgList: imgList, - ); - }, - transitionDuration: const Duration(milliseconds: 300), - transitionBuilder: (context, animation, secondaryAnimation, child) { - var tween = Tween(begin: 0.0, end: 1.0) - .chain(CurveTween(curve: Curves.linear)); - return FadeTransition( - opacity: animation.drive(tween), - child: child, - ); - }, + HeroDialogRoute( + builder: (context) => InteractiveviewerGallery( + sources: imgList, + initIndex: initialPage ?? 0, + onPageChanged: (int pageIndex) {}, + onDismissed: onDismissed, + ), ), ); } diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 4f281e26d..2c0070b7f 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -88,7 +88,7 @@ class Utils { Map followStatus = result['data']; showDialog( context: context, - builder: (_) { + builder: (context) { bool isSpecialFollowed = followStatus['special'] == 1; String text = isSpecialFollowed ? '移除特别关注' : '加入特别关注'; return AlertDialog( diff --git a/pubspec.lock b/pubspec.lock index e477322fa..ff3da9b39 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -488,22 +488,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.1" - extended_image: - dependency: "direct main" - description: - name: extended_image - sha256: "613875dc319f17546ea07499b5f0774755709a19a36dfde812e5eda9eb7a5c8c" - url: "https://pub.dev" - source: hosted - version: "9.0.7" - extended_image_library: - dependency: transitive - description: - name: extended_image_library - sha256: "9a94ec9314aa206cfa35f16145c3cd6e2c924badcc670eaaca8a3a8063a68cd7" - url: "https://pub.dev" - source: hosted - version: "4.0.5" extended_list: dependency: transitive description: @@ -933,14 +917,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" - http_client_helper: - dependency: transitive - description: - name: http_client_helper - sha256: "8a9127650734da86b5c73760de2b404494c968a3fd55602045ffec789dac3cb1" - url: "https://pub.dev" - source: hosted - version: "3.0.0" http_multi_server: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c845ba94f..aabdb2fec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,7 +49,7 @@ dependencies: # 图片 cached_network_image: ^3.4.1 - extended_image: ^9.0.7 + # extended_image: ^9.0.7 saver_gallery: ^4.0.0 # QRCode