mirror of
https://github.com/bggRGjQaUbCoE/PiliPlus.git
synced 2026-06-18 00:10:13 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bce73d9f16 | ||
|
|
6f30d2e331 | ||
|
|
556bda0d68 | ||
|
|
9d5eb55e26 | ||
|
|
110469961d | ||
|
|
fa348db7c5 | ||
|
|
3eac565b5e | ||
|
|
af40e489bc | ||
|
|
361eb4c614 | ||
|
|
7ace981f24 | ||
|
|
bfb2becb2d |
@@ -1,6 +1,3 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show clampDouble;
|
||||
|
||||
import 'package:PiliPlus/utils/platform_utils.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/rendering.dart'
|
||||
@@ -10,18 +7,14 @@ import 'package:flutter/rendering.dart'
|
||||
MultiChildLayoutParentData;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
enum TooltipType { top, right }
|
||||
|
||||
class CustomTooltip extends StatefulWidget {
|
||||
const CustomTooltip({
|
||||
super.key,
|
||||
this.type = TooltipType.top,
|
||||
required this.overlayWidget,
|
||||
required this.child,
|
||||
required this.indicator,
|
||||
});
|
||||
|
||||
final TooltipType type;
|
||||
final Widget child;
|
||||
final ValueGetter<Widget> overlayWidget;
|
||||
final ValueGetter<Widget> indicator;
|
||||
@@ -51,27 +44,20 @@ class _CustomTooltipState extends State<CustomTooltip> {
|
||||
longPressRecognizer.addPointer(event);
|
||||
}
|
||||
|
||||
Widget _buildCustomTooltipOverlay(BuildContext context) {
|
||||
final OverlayState overlayState = Overlay.of(
|
||||
context,
|
||||
debugRequiredFor: widget,
|
||||
Widget _buildCustomTooltipOverlay(
|
||||
BuildContext context,
|
||||
OverlayChildLayoutInfo layoutInfo,
|
||||
) {
|
||||
final target = MatrixUtils.transformPoint(
|
||||
layoutInfo.childPaintTransform,
|
||||
layoutInfo.childSize.topCenter(Offset.zero),
|
||||
);
|
||||
final RenderBox box = this.context.findRenderObject()! as RenderBox;
|
||||
final Offset target = box.localToGlobal(
|
||||
box.size.center(Offset.zero),
|
||||
ancestor: overlayState.context.findRenderObject(),
|
||||
);
|
||||
|
||||
final _CustomTooltipOverlay overlayChild = _CustomTooltipOverlay(
|
||||
verticalOffset: box.size.height / 2,
|
||||
horizontalOffset: box.size.width / 2,
|
||||
type: widget.type,
|
||||
target: target,
|
||||
onDismiss: _scheduleDismissTooltip,
|
||||
overlayWidget: widget.overlayWidget,
|
||||
indicator: widget.indicator,
|
||||
);
|
||||
|
||||
return SelectionContainer.maybeOf(context) == null
|
||||
? overlayChild
|
||||
: SelectionContainer.disabled(child: overlayChild);
|
||||
@@ -105,7 +91,7 @@ class _CustomTooltipState extends State<CustomTooltip> {
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
return OverlayPortal(
|
||||
return OverlayPortal.overlayChildLayoutBuilder(
|
||||
controller: _overlayController,
|
||||
overlayChildBuilder: _buildCustomTooltipOverlay,
|
||||
child: result,
|
||||
@@ -113,22 +99,14 @@ class _CustomTooltipState extends State<CustomTooltip> {
|
||||
}
|
||||
}
|
||||
|
||||
enum _ChildType { overlay, indicator }
|
||||
|
||||
class _CustomTooltipOverlay extends StatelessWidget {
|
||||
const _CustomTooltipOverlay({
|
||||
required this.verticalOffset,
|
||||
required this.horizontalOffset,
|
||||
required this.type,
|
||||
required this.target,
|
||||
required this.onDismiss,
|
||||
required this.overlayWidget,
|
||||
required this.indicator,
|
||||
});
|
||||
|
||||
final double verticalOffset;
|
||||
final double horizontalOffset;
|
||||
final TooltipType type;
|
||||
final Offset target;
|
||||
final VoidCallback onDismiss;
|
||||
final ValueGetter<Widget> overlayWidget;
|
||||
@@ -137,21 +115,12 @@ class _CustomTooltipOverlay extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _ToolTip(
|
||||
type: type,
|
||||
target: target,
|
||||
verticalOffset: verticalOffset,
|
||||
horizontalOffset: horizontalOffset,
|
||||
preferBelow: false,
|
||||
onTap: PlatformUtils.isMobile ? onDismiss : null,
|
||||
children: [
|
||||
LayoutId(
|
||||
id: _ChildType.indicator,
|
||||
child: indicator(),
|
||||
),
|
||||
LayoutId(
|
||||
id: _ChildType.overlay,
|
||||
child: overlayWidget(),
|
||||
),
|
||||
indicator(),
|
||||
overlayWidget(),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -161,28 +130,19 @@ class _ToolTip extends MultiChildRenderObjectWidget {
|
||||
const _ToolTip({
|
||||
super.children,
|
||||
this.onTap,
|
||||
required this.type,
|
||||
required this.target,
|
||||
required this.verticalOffset,
|
||||
required this.horizontalOffset,
|
||||
required this.preferBelow,
|
||||
});
|
||||
|
||||
final VoidCallback? onTap;
|
||||
final TooltipType type;
|
||||
final Offset target;
|
||||
final double verticalOffset;
|
||||
final double horizontalOffset;
|
||||
final bool preferBelow;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return _RenderToolTip(
|
||||
onTap: onTap,
|
||||
type: type,
|
||||
target: target,
|
||||
verticalOffset: verticalOffset,
|
||||
horizontalOffset: horizontalOffset,
|
||||
preferBelow: preferBelow,
|
||||
);
|
||||
}
|
||||
@@ -192,8 +152,6 @@ class _ToolTip extends MultiChildRenderObjectWidget {
|
||||
renderObject
|
||||
..onTap = onTap
|
||||
..target = target
|
||||
..verticalOffset = verticalOffset
|
||||
..horizontalOffset = horizontalOffset
|
||||
..preferBelow = preferBelow;
|
||||
}
|
||||
}
|
||||
@@ -204,15 +162,9 @@ class _RenderToolTip extends RenderBox
|
||||
RenderBoxContainerDefaultsMixin<RenderBox, MultiChildLayoutParentData> {
|
||||
_RenderToolTip({
|
||||
VoidCallback? onTap,
|
||||
required TooltipType type,
|
||||
required Offset target,
|
||||
required double verticalOffset,
|
||||
required double horizontalOffset,
|
||||
required bool preferBelow,
|
||||
}) : _type = type,
|
||||
_target = target,
|
||||
_verticalOffset = verticalOffset,
|
||||
_horizontalOffset = horizontalOffset,
|
||||
}) : _target = target,
|
||||
_preferBelow = preferBelow,
|
||||
_hitTestSelf = onTap != null {
|
||||
if (onTap != null) {
|
||||
@@ -246,8 +198,6 @@ class _RenderToolTip extends RenderBox
|
||||
}
|
||||
}
|
||||
|
||||
final TooltipType _type;
|
||||
|
||||
Offset _target;
|
||||
Offset get target => _target;
|
||||
set target(Offset value) {
|
||||
@@ -256,22 +206,6 @@ class _RenderToolTip extends RenderBox
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
double _verticalOffset;
|
||||
double get verticalOffset => _verticalOffset;
|
||||
set verticalOffset(double value) {
|
||||
if (_verticalOffset == value) return;
|
||||
_verticalOffset = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
double _horizontalOffset;
|
||||
double get horizontalOffset => _horizontalOffset;
|
||||
set horizontalOffset(double value) {
|
||||
if (_horizontalOffset == value) return;
|
||||
_horizontalOffset = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
bool _preferBelow;
|
||||
bool get preferBelow => _preferBelow;
|
||||
set preferBelow(bool value) {
|
||||
@@ -302,40 +236,18 @@ class _RenderToolTip extends RenderBox
|
||||
indicator.parentData as MultiChildLayoutParentData;
|
||||
final overlayParentData = overlay.parentData as MultiChildLayoutParentData;
|
||||
|
||||
switch (_type) {
|
||||
case TooltipType.top:
|
||||
Offset offset = _positionDependentBox(
|
||||
type: _type,
|
||||
size: size,
|
||||
childSize: overlaySize,
|
||||
target: target,
|
||||
verticalOffset: verticalOffset,
|
||||
horizontalOffset: horizontalOffset,
|
||||
preferBelow: preferBelow,
|
||||
);
|
||||
offset = Offset(offset.dx, offset.dy - indicatorSize.height + 1);
|
||||
overlayParentData.offset = offset;
|
||||
indicatorParentData.offset = Offset(
|
||||
target.dx - indicatorSize.width / 2,
|
||||
offset.dy + overlaySize.height - 1,
|
||||
);
|
||||
case TooltipType.right:
|
||||
Offset offset = _positionDependentBox(
|
||||
type: _type,
|
||||
size: size,
|
||||
childSize: overlaySize,
|
||||
target: target,
|
||||
verticalOffset: verticalOffset,
|
||||
horizontalOffset: horizontalOffset,
|
||||
preferBelow: preferBelow,
|
||||
);
|
||||
offset = Offset(offset.dx + indicatorSize.height - 1, offset.dy);
|
||||
overlayParentData.offset = offset;
|
||||
Offset(
|
||||
offset.dx - indicatorSize.width + 1,
|
||||
target.dy - indicatorSize.height / 2,
|
||||
);
|
||||
}
|
||||
Offset offset = positionDependentBox(
|
||||
size: size,
|
||||
childSize: overlaySize,
|
||||
target: target,
|
||||
preferBelow: preferBelow,
|
||||
);
|
||||
offset = Offset(offset.dx, offset.dy - indicatorSize.height + 1);
|
||||
overlayParentData.offset = offset;
|
||||
indicatorParentData.offset = Offset(
|
||||
target.dx - indicatorSize.width / 2,
|
||||
offset.dy + overlaySize.height - 1,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -357,19 +269,16 @@ class Triangle extends LeafRenderObjectWidget {
|
||||
super.key,
|
||||
required this.color,
|
||||
required this.size,
|
||||
this.type = .top,
|
||||
});
|
||||
|
||||
final Color color;
|
||||
final Size size;
|
||||
final TooltipType type;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return RenderTriangle(
|
||||
color: color,
|
||||
preferredSize: size,
|
||||
type: type,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -388,10 +297,8 @@ class RenderTriangle extends RenderBox {
|
||||
RenderTriangle({
|
||||
required Color color,
|
||||
required Size preferredSize,
|
||||
required TooltipType type,
|
||||
}) : _color = color,
|
||||
_preferredSize = preferredSize,
|
||||
_type = type;
|
||||
_preferredSize = preferredSize;
|
||||
|
||||
Color _color;
|
||||
Color get color => _color;
|
||||
@@ -408,8 +315,6 @@ class RenderTriangle extends RenderBox {
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
final TooltipType _type;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
size = constraints.constrain(_preferredSize);
|
||||
@@ -422,21 +327,11 @@ class RenderTriangle extends RenderBox {
|
||||
..color = color
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
Path path;
|
||||
switch (_type) {
|
||||
case TooltipType.top:
|
||||
path = Path()
|
||||
..moveTo(0, 0)
|
||||
..lineTo(size.width, 0)
|
||||
..lineTo(size.width / 2, size.height)
|
||||
..close();
|
||||
case TooltipType.right:
|
||||
path = Path()
|
||||
..moveTo(0, size.height / 2)
|
||||
..lineTo(size.width, 0)
|
||||
..lineTo(size.width, size.height)
|
||||
..close();
|
||||
}
|
||||
final path = Path()
|
||||
..moveTo(0, 0)
|
||||
..lineTo(size.width, 0)
|
||||
..lineTo(size.width / 2, size.height)
|
||||
..close();
|
||||
|
||||
context.canvas.drawPath(path, paint);
|
||||
}
|
||||
@@ -444,50 +339,3 @@ class RenderTriangle extends RenderBox {
|
||||
@override
|
||||
bool get isRepaintBoundary => true;
|
||||
}
|
||||
|
||||
Offset _positionDependentBox({
|
||||
required TooltipType type,
|
||||
required Size size,
|
||||
required Size childSize,
|
||||
required Offset target,
|
||||
required bool preferBelow,
|
||||
double verticalOffset = 0.0,
|
||||
double horizontalOffset = 0.0,
|
||||
double margin = 10.0,
|
||||
}) {
|
||||
switch (type) {
|
||||
case TooltipType.top:
|
||||
// VERTICAL DIRECTION
|
||||
final bool fitsBelow =
|
||||
target.dy + verticalOffset + childSize.height <= size.height - margin;
|
||||
final bool fitsAbove =
|
||||
target.dy - verticalOffset - childSize.height >= margin;
|
||||
final bool tooltipBelow = fitsAbove == fitsBelow
|
||||
? preferBelow
|
||||
: fitsBelow;
|
||||
final double y;
|
||||
if (tooltipBelow) {
|
||||
y = math.min(target.dy + verticalOffset, size.height - margin);
|
||||
} else {
|
||||
y = math.max(target.dy - verticalOffset - childSize.height, margin);
|
||||
} // HORIZONTAL DIRECTION
|
||||
final double flexibleSpace = size.width - childSize.width;
|
||||
final double x = flexibleSpace <= 2 * margin
|
||||
// If there's not enough horizontal space for margin + child, center the
|
||||
// child.
|
||||
? flexibleSpace / 2.0
|
||||
: clampDouble(
|
||||
target.dx - childSize.width / 2,
|
||||
margin,
|
||||
flexibleSpace - margin,
|
||||
);
|
||||
return Offset(x, y);
|
||||
case TooltipType.right:
|
||||
final double dy = math.max(margin, target.dy - childSize.height / 2);
|
||||
final double dx = math.min(
|
||||
target.dx + horizontalOffset,
|
||||
size.width - childSize.width - margin,
|
||||
);
|
||||
return Offset(dx, dy);
|
||||
}
|
||||
}
|
||||
|
||||
901
lib/common/widgets/flutter/text_intro/selectable_text.dart
Normal file
901
lib/common/widgets/flutter/text_intro/selectable_text.dart
Normal file
@@ -0,0 +1,901 @@
|
||||
// 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:ui' as ui show BoxHeightStyle, BoxWidthStyle;
|
||||
|
||||
import 'package:PiliPlus/common/widgets/flutter/text_intro/text_selection.dart';
|
||||
import 'package:flutter/cupertino.dart'
|
||||
hide TextSelectionGestureDetectorBuilder;
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart' hide TextSelectionGestureDetectorBuilder;
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
class _TextSpanEditingController extends TextEditingController {
|
||||
_TextSpanEditingController({required TextSpan textSpan})
|
||||
: _textSpan = textSpan,
|
||||
super(text: textSpan.toPlainText(includeSemanticsLabels: false));
|
||||
|
||||
final TextSpan _textSpan;
|
||||
|
||||
@override
|
||||
TextSpan buildTextSpan({
|
||||
required BuildContext context,
|
||||
TextStyle? style,
|
||||
required bool withComposing,
|
||||
}) {
|
||||
// This does not care about composing.
|
||||
return TextSpan(style: style, children: <TextSpan>[_textSpan]);
|
||||
}
|
||||
|
||||
@override
|
||||
set text(String? newText) {
|
||||
// This should never be reached.
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectableTextSelectionGestureDetectorBuilder
|
||||
extends CustomTextSelectionGestureDetectorBuilder {
|
||||
_SelectableTextSelectionGestureDetectorBuilder({
|
||||
required _SelectableTextState state,
|
||||
}) : _state = state,
|
||||
super(delegate: state);
|
||||
|
||||
final _SelectableTextState _state;
|
||||
|
||||
@override
|
||||
void onSingleTapUp(TapDragUpDetails details) {
|
||||
if (!delegate.selectionEnabled) {
|
||||
return;
|
||||
}
|
||||
super.onSingleTapUp(details);
|
||||
_state.widget.onTap?.call();
|
||||
}
|
||||
}
|
||||
|
||||
/// A run of selectable text with a single style.
|
||||
///
|
||||
/// Consider using [SelectionArea] or [SelectableRegion] instead, which enable
|
||||
/// selection on a widget subtree, including but not limited to [Text] widgets.
|
||||
///
|
||||
/// The [SelectableText] widget displays a string of text with a single style.
|
||||
/// The string might break across multiple lines or might all be displayed on
|
||||
/// the same line depending on the layout constraints.
|
||||
///
|
||||
/// {@youtube 560 315 https://www.youtube.com/watch?v=ZSU3ZXOs6hc}
|
||||
///
|
||||
/// The [style] argument is optional. When omitted, the text will use the style
|
||||
/// from the closest enclosing [DefaultTextStyle]. If the given style's
|
||||
/// [TextStyle.inherit] property is true (the default), the given style will
|
||||
/// be merged with the closest enclosing [DefaultTextStyle]. This merging
|
||||
/// behavior is useful, for example, to make the text bold while using the
|
||||
/// default font family and size.
|
||||
///
|
||||
/// {@macro flutter.material.textfield.wantKeepAlive}
|
||||
///
|
||||
/// {@tool snippet}
|
||||
///
|
||||
/// ```dart
|
||||
/// const SelectableText(
|
||||
/// 'Hello! How are you?',
|
||||
/// textAlign: TextAlign.center,
|
||||
/// style: TextStyle(fontWeight: FontWeight.bold),
|
||||
/// )
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
///
|
||||
/// Using the [SelectableText.rich] constructor, the [SelectableText] widget can
|
||||
/// display a paragraph with differently styled [TextSpan]s. The sample
|
||||
/// that follows displays "Hello beautiful world" with different styles
|
||||
/// for each word.
|
||||
///
|
||||
/// {@tool snippet}
|
||||
///
|
||||
/// ```dart
|
||||
/// const SelectableText.rich(
|
||||
/// TextSpan(
|
||||
/// text: 'Hello', // default text style
|
||||
/// children: <TextSpan>[
|
||||
/// TextSpan(text: ' beautiful ', style: TextStyle(fontStyle: FontStyle.italic)),
|
||||
/// TextSpan(text: 'world', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
/// ],
|
||||
/// ),
|
||||
/// )
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
///
|
||||
/// ## Interactivity
|
||||
///
|
||||
/// To make [SelectableText] react to touch events, use callback [onTap] to achieve
|
||||
/// the desired behavior.
|
||||
///
|
||||
/// ## Scrolling Considerations
|
||||
///
|
||||
/// If this [SelectableText] is not a descendant of [Scaffold] and is being used
|
||||
/// within a [Scrollable] or nested [Scrollable]s, consider placing a
|
||||
/// [ScrollNotificationObserver] above the root [Scrollable] that contains this
|
||||
/// [SelectableText] to ensure proper scroll coordination for [SelectableText]
|
||||
/// and its components like [TextSelectionOverlay].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [Text], which is the non selectable version of this widget.
|
||||
/// * [TextField], which is the editable version of this widget.
|
||||
/// * [SelectionArea], which enables the selection of multiple [Text] widgets
|
||||
/// and of other widgets.
|
||||
class SelectableText extends StatefulWidget {
|
||||
/// Creates a selectable text widget.
|
||||
///
|
||||
/// If the [style] argument is null, the text will use the style from the
|
||||
/// closest enclosing [DefaultTextStyle].
|
||||
///
|
||||
|
||||
/// If the [showCursor], [autofocus], [dragStartBehavior],
|
||||
/// [selectionHeightStyle], [selectionWidthStyle] and [data] arguments are
|
||||
/// specified, the [maxLines] argument must be greater than zero.
|
||||
const SelectableText(
|
||||
String this.data, {
|
||||
super.key,
|
||||
this.focusNode,
|
||||
this.style,
|
||||
this.strutStyle,
|
||||
this.textAlign,
|
||||
this.textDirection,
|
||||
@Deprecated(
|
||||
'Use textScaler instead. '
|
||||
'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. '
|
||||
'This feature was deprecated after v3.12.0-2.0.pre.',
|
||||
)
|
||||
this.textScaleFactor,
|
||||
this.textScaler,
|
||||
this.showCursor = false,
|
||||
this.autofocus = false,
|
||||
@Deprecated(
|
||||
'Use `contextMenuBuilder` instead. '
|
||||
'This feature was deprecated after v3.3.0-0.5.pre.',
|
||||
)
|
||||
this.toolbarOptions,
|
||||
this.minLines,
|
||||
this.maxLines,
|
||||
this.cursorWidth = 2.0,
|
||||
this.cursorHeight,
|
||||
this.cursorRadius,
|
||||
this.cursorColor,
|
||||
this.selectionColor,
|
||||
this.selectionHeightStyle,
|
||||
this.selectionWidthStyle,
|
||||
this.dragStartBehavior = DragStartBehavior.start,
|
||||
this.enableInteractiveSelection = true,
|
||||
this.selectionControls,
|
||||
this.onTap,
|
||||
this.scrollPhysics,
|
||||
this.scrollBehavior,
|
||||
this.semanticsLabel,
|
||||
this.textHeightBehavior,
|
||||
this.textWidthBasis,
|
||||
this.onSelectionChanged,
|
||||
this.contextMenuBuilder = _defaultContextMenuBuilder,
|
||||
this.magnifierConfiguration,
|
||||
}) : assert(maxLines == null || maxLines > 0),
|
||||
assert(minLines == null || minLines > 0),
|
||||
assert(
|
||||
(maxLines == null) || (minLines == null) || (maxLines >= minLines),
|
||||
"minLines can't be greater than maxLines",
|
||||
),
|
||||
assert(
|
||||
textScaler == null || textScaleFactor == null,
|
||||
'textScaleFactor is deprecated and cannot be specified when textScaler is specified.',
|
||||
),
|
||||
textSpan = null;
|
||||
|
||||
/// Creates a selectable text widget with a [TextSpan].
|
||||
///
|
||||
/// The [TextSpan.children] attribute of the [textSpan] parameter must only
|
||||
/// contain [TextSpan]s. Other types of [InlineSpan] are not allowed.
|
||||
const SelectableText.rich(
|
||||
TextSpan this.textSpan, {
|
||||
super.key,
|
||||
this.focusNode,
|
||||
this.style,
|
||||
this.strutStyle,
|
||||
this.textAlign,
|
||||
this.textDirection,
|
||||
@Deprecated(
|
||||
'Use textScaler instead. '
|
||||
'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. '
|
||||
'This feature was deprecated after v3.12.0-2.0.pre.',
|
||||
)
|
||||
this.textScaleFactor,
|
||||
this.textScaler,
|
||||
this.showCursor = false,
|
||||
this.autofocus = false,
|
||||
@Deprecated(
|
||||
'Use `contextMenuBuilder` instead. '
|
||||
'This feature was deprecated after v3.3.0-0.5.pre.',
|
||||
)
|
||||
this.toolbarOptions,
|
||||
this.minLines,
|
||||
this.maxLines,
|
||||
this.cursorWidth = 2.0,
|
||||
this.cursorHeight,
|
||||
this.cursorRadius,
|
||||
this.cursorColor,
|
||||
this.selectionColor,
|
||||
this.selectionHeightStyle,
|
||||
this.selectionWidthStyle,
|
||||
this.dragStartBehavior = DragStartBehavior.start,
|
||||
this.enableInteractiveSelection = true,
|
||||
this.selectionControls,
|
||||
this.onTap,
|
||||
this.scrollPhysics,
|
||||
this.scrollBehavior,
|
||||
this.semanticsLabel,
|
||||
this.textHeightBehavior,
|
||||
this.textWidthBasis,
|
||||
this.onSelectionChanged,
|
||||
this.contextMenuBuilder = _defaultContextMenuBuilder,
|
||||
this.magnifierConfiguration,
|
||||
}) : assert(maxLines == null || maxLines > 0),
|
||||
assert(minLines == null || minLines > 0),
|
||||
assert(
|
||||
(maxLines == null) || (minLines == null) || (maxLines >= minLines),
|
||||
"minLines can't be greater than maxLines",
|
||||
),
|
||||
assert(
|
||||
textScaler == null || textScaleFactor == null,
|
||||
'textScaleFactor is deprecated and cannot be specified when textScaler is specified.',
|
||||
),
|
||||
data = null;
|
||||
|
||||
/// The text to display.
|
||||
///
|
||||
/// This will be null if a [textSpan] is provided instead.
|
||||
final String? data;
|
||||
|
||||
/// The text to display as a [TextSpan].
|
||||
///
|
||||
/// This will be null if [data] is provided instead.
|
||||
final TextSpan? textSpan;
|
||||
|
||||
/// Defines the focus for this widget.
|
||||
///
|
||||
/// Text is only selectable when widget is focused.
|
||||
///
|
||||
/// The [focusNode] is a long-lived object that's typically managed by a
|
||||
/// [StatefulWidget] parent. See [FocusNode] for more information.
|
||||
///
|
||||
/// To give the focus to this widget, provide a [focusNode] and then
|
||||
/// use the current [FocusScope] to request the focus:
|
||||
///
|
||||
/// ```dart
|
||||
/// FocusScope.of(context).requestFocus(myFocusNode);
|
||||
/// ```
|
||||
///
|
||||
/// This happens automatically when the widget is tapped.
|
||||
///
|
||||
/// To be notified when the widget gains or loses the focus, add a listener
|
||||
/// to the [focusNode]:
|
||||
///
|
||||
/// ```dart
|
||||
/// myFocusNode.addListener(() { print(myFocusNode.hasFocus); });
|
||||
/// ```
|
||||
///
|
||||
/// If null, this widget will create its own [FocusNode] with
|
||||
/// [FocusNode.skipTraversal] parameter set to `true`, which causes the widget
|
||||
/// to be skipped over during focus traversal.
|
||||
final FocusNode? focusNode;
|
||||
|
||||
/// The style to use for the text.
|
||||
///
|
||||
/// If null, defaults [DefaultTextStyle] of context.
|
||||
final TextStyle? style;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.strutStyle}
|
||||
final StrutStyle? strutStyle;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.textAlign}
|
||||
final TextAlign? textAlign;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.textDirection}
|
||||
final TextDirection? textDirection;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.textScaleFactor}
|
||||
@Deprecated(
|
||||
'Use textScaler instead. '
|
||||
'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. '
|
||||
'This feature was deprecated after v3.12.0-2.0.pre.',
|
||||
)
|
||||
final double? textScaleFactor;
|
||||
|
||||
/// {@macro flutter.painting.textPainter.textScaler}
|
||||
final TextScaler? textScaler;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.autofocus}
|
||||
final bool autofocus;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.minLines}
|
||||
final int? minLines;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.maxLines}
|
||||
final int? maxLines;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.showCursor}
|
||||
final bool showCursor;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.cursorWidth}
|
||||
final double cursorWidth;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.cursorHeight}
|
||||
final double? cursorHeight;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.cursorRadius}
|
||||
final Radius? cursorRadius;
|
||||
|
||||
/// The color of the cursor.
|
||||
///
|
||||
/// The cursor indicates the current text insertion point.
|
||||
///
|
||||
/// If null then [DefaultSelectionStyle.cursorColor] is used. If that is also
|
||||
/// null and [ThemeData.platform] is [TargetPlatform.iOS] or
|
||||
/// [TargetPlatform.macOS], then [CupertinoThemeData.primaryColor] is used.
|
||||
/// Otherwise [ColorScheme.primary] of [ThemeData.colorScheme] is used.
|
||||
final Color? cursorColor;
|
||||
|
||||
/// The color to use when painting the selection.
|
||||
///
|
||||
/// If this property is null, this widget gets the selection color from the
|
||||
/// inherited [DefaultSelectionStyle] (if any); if none, the selection
|
||||
/// color is derived from the [CupertinoThemeData.primaryColor] on
|
||||
/// Apple platforms and [ColorScheme.primary] of [ThemeData.colorScheme] on
|
||||
/// other platforms.
|
||||
final Color? selectionColor;
|
||||
|
||||
/// Controls how tall the selection highlight boxes are computed to be.
|
||||
///
|
||||
/// See [ui.BoxHeightStyle] for details on available styles.
|
||||
final ui.BoxHeightStyle? selectionHeightStyle;
|
||||
|
||||
/// Controls how wide the selection highlight boxes are computed to be.
|
||||
///
|
||||
/// See [ui.BoxWidthStyle] for details on available styles.
|
||||
final ui.BoxWidthStyle? selectionWidthStyle;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.enableInteractiveSelection}
|
||||
final bool enableInteractiveSelection;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.selectionControls}
|
||||
final TextSelectionControls? selectionControls;
|
||||
|
||||
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
|
||||
final DragStartBehavior dragStartBehavior;
|
||||
|
||||
/// Configuration of toolbar options.
|
||||
///
|
||||
/// Paste and cut will be disabled regardless.
|
||||
///
|
||||
/// If not set, select all and copy will be enabled by default.
|
||||
@Deprecated(
|
||||
'Use `contextMenuBuilder` instead. '
|
||||
'This feature was deprecated after v3.3.0-0.5.pre.',
|
||||
)
|
||||
final ToolbarOptions? toolbarOptions;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.selectionEnabled}
|
||||
bool get selectionEnabled => enableInteractiveSelection;
|
||||
|
||||
/// Called when the user taps on this selectable text.
|
||||
///
|
||||
/// The selectable text builds a [GestureDetector] to handle input events like tap,
|
||||
/// to trigger focus requests, to move the caret, adjust the selection, etc.
|
||||
/// Handling some of those events by wrapping the selectable text with a competing
|
||||
/// GestureDetector is problematic.
|
||||
///
|
||||
/// To unconditionally handle taps, without interfering with the selectable text's
|
||||
/// internal gesture detector, provide this callback.
|
||||
///
|
||||
/// To be notified when the text field gains or loses the focus, provide a
|
||||
/// [focusNode] and add a listener to that.
|
||||
///
|
||||
/// To listen to arbitrary pointer events without competing with the
|
||||
/// selectable text's internal gesture detector, use a [Listener].
|
||||
final GestureTapCallback? onTap;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.scrollPhysics}
|
||||
final ScrollPhysics? scrollPhysics;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.scrollBehavior}
|
||||
final ScrollBehavior? scrollBehavior;
|
||||
|
||||
/// {@macro flutter.widgets.Text.semanticsLabel}
|
||||
final String? semanticsLabel;
|
||||
|
||||
/// {@macro dart.ui.textHeightBehavior}
|
||||
final TextHeightBehavior? textHeightBehavior;
|
||||
|
||||
/// {@macro flutter.painting.textPainter.textWidthBasis}
|
||||
final TextWidthBasis? textWidthBasis;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.onSelectionChanged}
|
||||
final SelectionChangedCallback? onSelectionChanged;
|
||||
|
||||
/// {@macro flutter.widgets.EditableText.contextMenuBuilder}
|
||||
final EditableTextContextMenuBuilder? contextMenuBuilder;
|
||||
|
||||
static Widget _defaultContextMenuBuilder(
|
||||
BuildContext context,
|
||||
EditableTextState editableTextState,
|
||||
) {
|
||||
return AdaptiveTextSelectionToolbar.editableText(
|
||||
editableTextState: editableTextState,
|
||||
);
|
||||
}
|
||||
|
||||
/// The configuration for the magnifier used when the text is selected.
|
||||
///
|
||||
/// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier]
|
||||
/// on Android, and builds nothing on all other platforms. To suppress the
|
||||
/// magnifier, consider passing [TextMagnifierConfiguration.disabled].
|
||||
///
|
||||
/// {@macro flutter.widgets.magnifier.intro}
|
||||
final TextMagnifierConfiguration? magnifierConfiguration;
|
||||
|
||||
@override
|
||||
State<SelectableText> createState() => _SelectableTextState();
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties
|
||||
..add(
|
||||
DiagnosticsProperty<String>('data', data, defaultValue: null),
|
||||
)
|
||||
..add(
|
||||
DiagnosticsProperty<String>(
|
||||
'semanticsLabel',
|
||||
semanticsLabel,
|
||||
defaultValue: null,
|
||||
),
|
||||
)
|
||||
..add(
|
||||
DiagnosticsProperty<FocusNode>(
|
||||
'focusNode',
|
||||
focusNode,
|
||||
defaultValue: null,
|
||||
),
|
||||
)
|
||||
..add(
|
||||
DiagnosticsProperty<TextStyle>('style', style, defaultValue: null),
|
||||
)
|
||||
..add(
|
||||
DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false),
|
||||
)
|
||||
..add(
|
||||
DiagnosticsProperty<bool>(
|
||||
'showCursor',
|
||||
showCursor,
|
||||
defaultValue: false,
|
||||
),
|
||||
)
|
||||
..add(IntProperty('minLines', minLines, defaultValue: null))
|
||||
..add(IntProperty('maxLines', maxLines, defaultValue: null))
|
||||
..add(
|
||||
EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: null),
|
||||
)
|
||||
..add(
|
||||
EnumProperty<TextDirection>(
|
||||
'textDirection',
|
||||
textDirection,
|
||||
defaultValue: null,
|
||||
),
|
||||
)
|
||||
..add(
|
||||
DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null),
|
||||
)
|
||||
..add(
|
||||
DiagnosticsProperty<TextScaler>(
|
||||
'textScaler',
|
||||
textScaler,
|
||||
defaultValue: null,
|
||||
),
|
||||
)
|
||||
..add(
|
||||
DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0),
|
||||
)
|
||||
..add(
|
||||
DoubleProperty('cursorHeight', cursorHeight, defaultValue: null),
|
||||
)
|
||||
..add(
|
||||
DiagnosticsProperty<Radius>(
|
||||
'cursorRadius',
|
||||
cursorRadius,
|
||||
defaultValue: null,
|
||||
),
|
||||
)
|
||||
..add(
|
||||
DiagnosticsProperty<Color>(
|
||||
'cursorColor',
|
||||
cursorColor,
|
||||
defaultValue: null,
|
||||
),
|
||||
)
|
||||
..add(
|
||||
DiagnosticsProperty<Color>(
|
||||
'selectionColor',
|
||||
selectionColor,
|
||||
defaultValue: null,
|
||||
),
|
||||
)
|
||||
..add(
|
||||
FlagProperty(
|
||||
'selectionEnabled',
|
||||
value: selectionEnabled,
|
||||
defaultValue: true,
|
||||
ifFalse: 'selection disabled',
|
||||
),
|
||||
)
|
||||
..add(
|
||||
DiagnosticsProperty<TextSelectionControls>(
|
||||
'selectionControls',
|
||||
selectionControls,
|
||||
defaultValue: null,
|
||||
),
|
||||
)
|
||||
..add(
|
||||
DiagnosticsProperty<ScrollPhysics>(
|
||||
'scrollPhysics',
|
||||
scrollPhysics,
|
||||
defaultValue: null,
|
||||
),
|
||||
)
|
||||
..add(
|
||||
DiagnosticsProperty<ScrollBehavior>(
|
||||
'scrollBehavior',
|
||||
scrollBehavior,
|
||||
defaultValue: null,
|
||||
),
|
||||
)
|
||||
..add(
|
||||
DiagnosticsProperty<TextHeightBehavior>(
|
||||
'textHeightBehavior',
|
||||
textHeightBehavior,
|
||||
defaultValue: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectableTextState extends State<SelectableText>
|
||||
implements TextSelectionGestureDetectorBuilderDelegate {
|
||||
EditableTextState? get _editableText => editableTextKey.currentState;
|
||||
|
||||
late _TextSpanEditingController _controller;
|
||||
|
||||
FocusNode? _focusNode;
|
||||
FocusNode get _effectiveFocusNode =>
|
||||
widget.focusNode ?? (_focusNode ??= FocusNode(skipTraversal: true));
|
||||
|
||||
bool _showSelectionHandles = false;
|
||||
|
||||
late _SelectableTextSelectionGestureDetectorBuilder
|
||||
_selectionGestureDetectorBuilder;
|
||||
|
||||
// API for TextSelectionGestureDetectorBuilderDelegate.
|
||||
@override
|
||||
late bool forcePressEnabled;
|
||||
|
||||
@override
|
||||
final GlobalKey<EditableTextState> editableTextKey =
|
||||
GlobalKey<EditableTextState>();
|
||||
|
||||
@override
|
||||
bool get selectionEnabled => widget.selectionEnabled;
|
||||
// End of API for TextSelectionGestureDetectorBuilderDelegate.
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectionGestureDetectorBuilder =
|
||||
_SelectableTextSelectionGestureDetectorBuilder(state: this);
|
||||
_controller = _TextSpanEditingController(
|
||||
textSpan: widget.textSpan ?? TextSpan(text: widget.data),
|
||||
);
|
||||
_controller.addListener(_onControllerChanged);
|
||||
_effectiveFocusNode.addListener(_handleFocusChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SelectableText oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.data != oldWidget.data ||
|
||||
widget.textSpan != oldWidget.textSpan) {
|
||||
_controller.removeListener(_onControllerChanged);
|
||||
_controller.dispose();
|
||||
_controller = _TextSpanEditingController(
|
||||
textSpan: widget.textSpan ?? TextSpan(text: widget.data),
|
||||
);
|
||||
_controller.addListener(_onControllerChanged);
|
||||
}
|
||||
if (widget.focusNode != oldWidget.focusNode) {
|
||||
(oldWidget.focusNode ?? _focusNode)?.removeListener(_handleFocusChanged);
|
||||
(widget.focusNode ?? _focusNode)?.addListener(_handleFocusChanged);
|
||||
}
|
||||
if (_effectiveFocusNode.hasFocus && _controller.selection.isCollapsed) {
|
||||
_showSelectionHandles = false;
|
||||
} else {
|
||||
_showSelectionHandles = true;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_effectiveFocusNode.removeListener(_handleFocusChanged);
|
||||
_focusNode?.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onControllerChanged() {
|
||||
final bool showSelectionHandles =
|
||||
!_effectiveFocusNode.hasFocus || !_controller.selection.isCollapsed;
|
||||
if (showSelectionHandles == _showSelectionHandles) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_showSelectionHandles = showSelectionHandles;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleFocusChanged() {
|
||||
if (!_effectiveFocusNode.hasFocus &&
|
||||
SchedulerBinding.instance.lifecycleState == AppLifecycleState.resumed) {
|
||||
// We should only clear the selection when this SelectableText loses
|
||||
// focus while the application is currently running. It is possible
|
||||
// that the application is not currently running, for example on desktop
|
||||
// platforms, clicking on a different window switches the focus to
|
||||
// the new window causing the Flutter application to go inactive. In this
|
||||
// case we want to retain the selection so it remains when we return to
|
||||
// the Flutter application.
|
||||
_controller.value = TextEditingValue(text: _controller.value.text);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSelectionChanged(
|
||||
TextSelection selection,
|
||||
SelectionChangedCause? cause,
|
||||
) {
|
||||
final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause);
|
||||
if (willShowSelectionHandles != _showSelectionHandles) {
|
||||
setState(() {
|
||||
_showSelectionHandles = willShowSelectionHandles;
|
||||
});
|
||||
}
|
||||
|
||||
widget.onSelectionChanged?.call(selection, cause);
|
||||
|
||||
switch (Theme.of(context).platform) {
|
||||
case TargetPlatform.iOS:
|
||||
case TargetPlatform.macOS:
|
||||
if (cause == SelectionChangedCause.longPress) {
|
||||
_editableText?.bringIntoView(selection.base);
|
||||
}
|
||||
return;
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.linux:
|
||||
case TargetPlatform.windows:
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle the toolbar when a selection handle is tapped.
|
||||
void _handleSelectionHandleTapped() {
|
||||
if (_controller.selection.isCollapsed) {
|
||||
_editableText!.toggleToolbar();
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldShowSelectionHandles(SelectionChangedCause? cause) {
|
||||
// When the text field is activated by something that doesn't trigger the
|
||||
// selection overlay, we shouldn't show the handles either.
|
||||
if (!_selectionGestureDetectorBuilder.shouldShowSelectionToolbar) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_controller.selection.isCollapsed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cause == SelectionChangedCause.keyboard) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cause == SelectionChangedCause.longPress) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_controller.text.isNotEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// TODO(garyq): Assert to block WidgetSpans from being used here are removed,
|
||||
// but we still do not yet have nice handling of things like carets, clipboard,
|
||||
// and other features. We should add proper support. Currently, caret handling
|
||||
// is blocked on SkParagraph switch and https://github.com/flutter/engine/pull/27010
|
||||
// should be landed in SkParagraph after the switch is complete.
|
||||
assert(debugCheckHasMediaQuery(context));
|
||||
assert(debugCheckHasDirectionality(context));
|
||||
assert(
|
||||
!(widget.style != null &&
|
||||
!widget.style!.inherit &&
|
||||
(widget.style!.fontSize == null ||
|
||||
widget.style!.textBaseline == null)),
|
||||
'inherit false style must supply fontSize and textBaseline',
|
||||
);
|
||||
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final DefaultSelectionStyle selectionStyle = DefaultSelectionStyle.of(
|
||||
context,
|
||||
);
|
||||
final FocusNode focusNode = _effectiveFocusNode;
|
||||
|
||||
TextSelectionControls? textSelectionControls = widget.selectionControls;
|
||||
final bool paintCursorAboveText;
|
||||
final bool cursorOpacityAnimates;
|
||||
Offset? cursorOffset;
|
||||
final Color cursorColor;
|
||||
final Color selectionColor;
|
||||
Radius? cursorRadius = widget.cursorRadius;
|
||||
|
||||
switch (theme.platform) {
|
||||
case TargetPlatform.iOS:
|
||||
final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
|
||||
forcePressEnabled = true;
|
||||
textSelectionControls ??= cupertinoTextSelectionHandleControls;
|
||||
paintCursorAboveText = true;
|
||||
cursorOpacityAnimates = true;
|
||||
cursorColor =
|
||||
widget.cursorColor ??
|
||||
selectionStyle.cursorColor ??
|
||||
cupertinoTheme.primaryColor;
|
||||
selectionColor =
|
||||
selectionStyle.selectionColor ??
|
||||
cupertinoTheme.primaryColor.withOpacity(0.40);
|
||||
cursorRadius ??= const Radius.circular(2.0);
|
||||
cursorOffset = Offset(
|
||||
iOSHorizontalOffset / MediaQuery.devicePixelRatioOf(context),
|
||||
0,
|
||||
);
|
||||
|
||||
case TargetPlatform.macOS:
|
||||
final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
|
||||
forcePressEnabled = false;
|
||||
textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls;
|
||||
paintCursorAboveText = true;
|
||||
cursorOpacityAnimates = true;
|
||||
cursorColor =
|
||||
widget.cursorColor ??
|
||||
selectionStyle.cursorColor ??
|
||||
cupertinoTheme.primaryColor;
|
||||
selectionColor =
|
||||
selectionStyle.selectionColor ??
|
||||
cupertinoTheme.primaryColor.withOpacity(0.40);
|
||||
cursorRadius ??= const Radius.circular(2.0);
|
||||
cursorOffset = Offset(
|
||||
iOSHorizontalOffset / MediaQuery.devicePixelRatioOf(context),
|
||||
0,
|
||||
);
|
||||
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
forcePressEnabled = false;
|
||||
textSelectionControls ??= materialTextSelectionHandleControls;
|
||||
paintCursorAboveText = false;
|
||||
cursorOpacityAnimates = false;
|
||||
cursorColor =
|
||||
widget.cursorColor ??
|
||||
selectionStyle.cursorColor ??
|
||||
theme.colorScheme.primary;
|
||||
selectionColor =
|
||||
selectionStyle.selectionColor ??
|
||||
theme.colorScheme.primary.withOpacity(0.40);
|
||||
|
||||
case TargetPlatform.linux:
|
||||
case TargetPlatform.windows:
|
||||
forcePressEnabled = false;
|
||||
textSelectionControls ??= desktopTextSelectionHandleControls;
|
||||
paintCursorAboveText = false;
|
||||
cursorOpacityAnimates = false;
|
||||
cursorColor =
|
||||
widget.cursorColor ??
|
||||
selectionStyle.cursorColor ??
|
||||
theme.colorScheme.primary;
|
||||
selectionColor =
|
||||
selectionStyle.selectionColor ??
|
||||
theme.colorScheme.primary.withOpacity(0.40);
|
||||
}
|
||||
|
||||
final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context);
|
||||
TextStyle? effectiveTextStyle = widget.style;
|
||||
if (effectiveTextStyle == null || effectiveTextStyle.inherit) {
|
||||
effectiveTextStyle = defaultTextStyle.style.merge(
|
||||
widget.style ?? _controller._textSpan.style,
|
||||
);
|
||||
}
|
||||
final TextScaler? effectiveScaler =
|
||||
widget.textScaler ??
|
||||
switch (widget.textScaleFactor) {
|
||||
null => null,
|
||||
final double textScaleFactor => TextScaler.linear(textScaleFactor),
|
||||
};
|
||||
final Widget child = RepaintBoundary(
|
||||
child: EditableText(
|
||||
key: editableTextKey,
|
||||
style: effectiveTextStyle,
|
||||
readOnly: true,
|
||||
toolbarOptions: widget.toolbarOptions,
|
||||
textWidthBasis:
|
||||
widget.textWidthBasis ?? defaultTextStyle.textWidthBasis,
|
||||
textHeightBehavior:
|
||||
widget.textHeightBehavior ?? defaultTextStyle.textHeightBehavior,
|
||||
showSelectionHandles: _showSelectionHandles,
|
||||
showCursor: widget.showCursor,
|
||||
controller: _controller,
|
||||
focusNode: focusNode,
|
||||
strutStyle: widget.strutStyle ?? const StrutStyle(),
|
||||
textAlign:
|
||||
widget.textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
|
||||
textDirection: widget.textDirection,
|
||||
textScaler: effectiveScaler,
|
||||
autofocus: widget.autofocus,
|
||||
forceLine: false,
|
||||
minLines: widget.minLines,
|
||||
maxLines: widget.maxLines ?? defaultTextStyle.maxLines,
|
||||
selectionColor: widget.selectionColor ?? selectionColor,
|
||||
selectionControls: widget.selectionEnabled
|
||||
? textSelectionControls
|
||||
: null,
|
||||
onSelectionChanged: _handleSelectionChanged,
|
||||
onSelectionHandleTapped: _handleSelectionHandleTapped,
|
||||
rendererIgnoresPointer: true,
|
||||
cursorWidth: widget.cursorWidth,
|
||||
cursorHeight: widget.cursorHeight,
|
||||
cursorRadius: cursorRadius,
|
||||
cursorColor: cursorColor,
|
||||
selectionHeightStyle: widget.selectionHeightStyle,
|
||||
selectionWidthStyle: widget.selectionWidthStyle,
|
||||
cursorOpacityAnimates: cursorOpacityAnimates,
|
||||
cursorOffset: cursorOffset,
|
||||
paintCursorAboveText: paintCursorAboveText,
|
||||
backgroundCursorColor: CupertinoColors.inactiveGray,
|
||||
enableInteractiveSelection: widget.enableInteractiveSelection,
|
||||
magnifierConfiguration:
|
||||
widget.magnifierConfiguration ??
|
||||
TextMagnifier.adaptiveMagnifierConfiguration,
|
||||
dragStartBehavior: widget.dragStartBehavior,
|
||||
scrollPhysics: widget.scrollPhysics,
|
||||
scrollBehavior: widget.scrollBehavior,
|
||||
autofillHints: null,
|
||||
contextMenuBuilder: widget.contextMenuBuilder,
|
||||
),
|
||||
);
|
||||
|
||||
return Semantics(
|
||||
label: widget.semanticsLabel,
|
||||
excludeSemantics: widget.semanticsLabel != null,
|
||||
onLongPress: () {
|
||||
_effectiveFocusNode.requestFocus();
|
||||
},
|
||||
child: _selectionGestureDetectorBuilder.buildGestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1085
lib/common/widgets/flutter/text_intro/tap_and_drag.dart
Normal file
1085
lib/common/widgets/flutter/text_intro/tap_and_drag.dart
Normal file
File diff suppressed because it is too large
Load Diff
22
lib/common/widgets/flutter/text_intro/text.dart
Normal file
22
lib/common/widgets/flutter/text_intro/text.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:PiliPlus/common/widgets/flutter/text_intro/selectable_text.dart';
|
||||
import 'package:PiliPlus/utils/platform_utils.dart';
|
||||
import 'package:flutter/material.dart' hide SelectableText;
|
||||
|
||||
Widget selectableRichText(
|
||||
TextSpan textSpan, {
|
||||
TextStyle? style,
|
||||
}) {
|
||||
if (PlatformUtils.isDesktop) {
|
||||
return SelectionArea(
|
||||
child: Text.rich(
|
||||
style: style,
|
||||
textSpan,
|
||||
),
|
||||
);
|
||||
}
|
||||
return SelectableText.rich(
|
||||
style: style,
|
||||
textSpan,
|
||||
scrollPhysics: const NeverScrollableScrollPhysics(),
|
||||
);
|
||||
}
|
||||
419
lib/common/widgets/flutter/text_intro/text_selection.dart
Normal file
419
lib/common/widgets/flutter/text_intro/text_selection.dart
Normal file
@@ -0,0 +1,419 @@
|
||||
// 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:PiliPlus/common/widgets/flutter/text_intro/tap_and_drag.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart'
|
||||
hide BaseTapAndDragGestureRecognizer, TapAndHorizontalDragGestureRecognizer;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CustomTextSelectionGestureDetectorBuilder
|
||||
extends TextSelectionGestureDetectorBuilder {
|
||||
CustomTextSelectionGestureDetectorBuilder({required super.delegate});
|
||||
|
||||
@override
|
||||
Widget buildGestureDetector({
|
||||
Key? key,
|
||||
HitTestBehavior? behavior,
|
||||
required Widget child,
|
||||
}) {
|
||||
return TextSelectionGestureDetector(
|
||||
key: key,
|
||||
onTapTrackStart: onTapTrackStart,
|
||||
onTapTrackReset: onTapTrackReset,
|
||||
onTapDown: onTapDown,
|
||||
onForcePressStart: delegate.forcePressEnabled ? onForcePressStart : null,
|
||||
onForcePressEnd: delegate.forcePressEnabled ? onForcePressEnd : null,
|
||||
onSecondaryTap: onSecondaryTap,
|
||||
onSecondaryTapDown: onSecondaryTapDown,
|
||||
onSingleTapUp: onSingleTapUp,
|
||||
onSingleTapCancel: onSingleTapCancel,
|
||||
onUserTap: onUserTap,
|
||||
onSingleLongTapStart: onSingleLongTapStart,
|
||||
onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate,
|
||||
onSingleLongTapEnd: onSingleLongTapEnd,
|
||||
onSingleLongTapCancel: onSingleLongTapCancel,
|
||||
onDoubleTapDown: onDoubleTapDown,
|
||||
onTripleTapDown: onTripleTapDown,
|
||||
onDragSelectionStart: onDragSelectionStart,
|
||||
onDragSelectionUpdate: onDragSelectionUpdate,
|
||||
onDragSelectionEnd: onDragSelectionEnd,
|
||||
onUserTapAlwaysCalled: onUserTapAlwaysCalled,
|
||||
behavior: behavior,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A gesture detector to respond to non-exclusive event chains for a text field.
|
||||
///
|
||||
/// An ordinary [GestureDetector] configured to handle events like tap and
|
||||
/// double tap will only recognize one or the other. This widget detects both:
|
||||
/// the first tap and then any subsequent taps that occurs within a time limit
|
||||
/// after the first.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [TextField], a Material text field which uses this gesture detector.
|
||||
/// * [CupertinoTextField], a Cupertino text field which uses this gesture
|
||||
/// detector.
|
||||
class TextSelectionGestureDetector extends StatefulWidget {
|
||||
/// Create a [TextSelectionGestureDetector].
|
||||
///
|
||||
/// Multiple callbacks can be called for one sequence of input gesture.
|
||||
const TextSelectionGestureDetector({
|
||||
super.key,
|
||||
this.onTapTrackStart,
|
||||
this.onTapTrackReset,
|
||||
this.onTapDown,
|
||||
this.onForcePressStart,
|
||||
this.onForcePressEnd,
|
||||
this.onSecondaryTap,
|
||||
this.onSecondaryTapDown,
|
||||
this.onSingleTapUp,
|
||||
this.onSingleTapCancel,
|
||||
this.onUserTap,
|
||||
this.onSingleLongTapStart,
|
||||
this.onSingleLongTapMoveUpdate,
|
||||
this.onSingleLongTapEnd,
|
||||
this.onSingleLongTapCancel,
|
||||
this.onDoubleTapDown,
|
||||
this.onTripleTapDown,
|
||||
this.onDragSelectionStart,
|
||||
this.onDragSelectionUpdate,
|
||||
this.onDragSelectionEnd,
|
||||
this.onUserTapAlwaysCalled = false,
|
||||
this.behavior,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
/// {@template flutter.gestures.selectionrecognizers.TextSelectionGestureDetector.onTapTrackStart}
|
||||
/// Callback used to indicate that a tap tracking has started upon
|
||||
/// a [PointerDownEvent].
|
||||
/// {@endtemplate}
|
||||
final VoidCallback? onTapTrackStart;
|
||||
|
||||
/// {@template flutter.gestures.selectionrecognizers.TextSelectionGestureDetector.onTapTrackReset}
|
||||
/// Callback used to indicate that a tap tracking has been reset which
|
||||
/// happens on the next [PointerDownEvent] after the timer between two taps
|
||||
/// elapses, the recognizer loses the arena, the gesture is cancelled or
|
||||
/// the recognizer is disposed of.
|
||||
/// {@endtemplate}
|
||||
final VoidCallback? onTapTrackReset;
|
||||
|
||||
/// Called for every tap down including every tap down that's part of a
|
||||
/// double click or a long press, except touches that include enough movement
|
||||
/// to not qualify as taps (e.g. pans and flings).
|
||||
final GestureTapDragDownCallback? onTapDown;
|
||||
|
||||
/// Called when a pointer has tapped down and the force of the pointer has
|
||||
/// just become greater than [ForcePressGestureRecognizer.startPressure].
|
||||
final GestureForcePressStartCallback? onForcePressStart;
|
||||
|
||||
/// Called when a pointer that had previously triggered [onForcePressStart] is
|
||||
/// lifted off the screen.
|
||||
final GestureForcePressEndCallback? onForcePressEnd;
|
||||
|
||||
/// Called for a tap event with the secondary mouse button.
|
||||
final GestureTapCallback? onSecondaryTap;
|
||||
|
||||
/// Called for a tap down event with the secondary mouse button.
|
||||
final GestureTapDownCallback? onSecondaryTapDown;
|
||||
|
||||
/// Called for the first tap in a series of taps, consecutive taps do not call
|
||||
/// this method.
|
||||
///
|
||||
/// For example, if the detector was configured with [onTapDown] and
|
||||
/// [onDoubleTapDown], three quick taps would be recognized as a single tap
|
||||
/// down, followed by a tap up, then a double tap down, followed by a single tap down.
|
||||
final GestureTapDragUpCallback? onSingleTapUp;
|
||||
|
||||
/// Called for each touch that becomes recognized as a gesture that is not a
|
||||
/// short tap, such as a long tap or drag. It is called at the moment when
|
||||
/// another gesture from the touch is recognized.
|
||||
final GestureCancelCallback? onSingleTapCancel;
|
||||
|
||||
/// Called for the first tap in a series of taps when [onUserTapAlwaysCalled] is
|
||||
/// disabled, which is the default behavior.
|
||||
///
|
||||
/// When [onUserTapAlwaysCalled] is enabled, this is called for every tap,
|
||||
/// including consecutive taps.
|
||||
final GestureTapCallback? onUserTap;
|
||||
|
||||
/// Called for a single long tap that's sustained for longer than
|
||||
/// [kLongPressTimeout] but not necessarily lifted. Not called for a
|
||||
/// double-tap-hold, which calls [onDoubleTapDown] instead.
|
||||
final GestureLongPressStartCallback? onSingleLongTapStart;
|
||||
|
||||
/// Called after [onSingleLongTapStart] when the pointer is dragged.
|
||||
final GestureLongPressMoveUpdateCallback? onSingleLongTapMoveUpdate;
|
||||
|
||||
/// Called after [onSingleLongTapStart] when the pointer is lifted.
|
||||
final GestureLongPressEndCallback? onSingleLongTapEnd;
|
||||
|
||||
/// Called after [onSingleLongTapStart] when the pointer is canceled.
|
||||
final GestureLongPressCancelCallback? onSingleLongTapCancel;
|
||||
|
||||
/// Called after a momentary hold or a short tap that is close in space and
|
||||
/// time (within [kDoubleTapTimeout]) to a previous short tap.
|
||||
final GestureTapDragDownCallback? onDoubleTapDown;
|
||||
|
||||
/// Called after a momentary hold or a short tap that is close in space and
|
||||
/// time (within [kDoubleTapTimeout]) to a previous double-tap.
|
||||
final GestureTapDragDownCallback? onTripleTapDown;
|
||||
|
||||
/// Called when a mouse starts dragging to select text.
|
||||
final GestureTapDragStartCallback? onDragSelectionStart;
|
||||
|
||||
/// Called repeatedly as a mouse moves while dragging.
|
||||
final GestureTapDragUpdateCallback? onDragSelectionUpdate;
|
||||
|
||||
/// Called when a mouse that was previously dragging is released.
|
||||
final GestureTapDragEndCallback? onDragSelectionEnd;
|
||||
|
||||
/// Whether [onUserTap] will be called for all taps including consecutive taps.
|
||||
///
|
||||
/// Defaults to false, so [onUserTap] is only called for each distinct tap.
|
||||
final bool onUserTapAlwaysCalled;
|
||||
|
||||
/// How this gesture detector should behave during hit testing.
|
||||
///
|
||||
/// This defaults to [HitTestBehavior.deferToChild].
|
||||
final HitTestBehavior? behavior;
|
||||
|
||||
/// Child below this widget.
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _TextSelectionGestureDetectorState();
|
||||
}
|
||||
|
||||
class _TextSelectionGestureDetectorState
|
||||
extends State<TextSelectionGestureDetector> {
|
||||
// Converts the details.consecutiveTapCount from a TapAndDrag*Details object,
|
||||
// which can grow to be infinitely large, to a value between 1 and 3. The value
|
||||
// that the raw count is converted to is based on the default observed behavior
|
||||
// on the native platforms.
|
||||
//
|
||||
// This method should be used in all instances when details.consecutiveTapCount
|
||||
// would be used.
|
||||
static int _getEffectiveConsecutiveTapCount(int rawCount) {
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.linux:
|
||||
// From observation, these platform's reset their tap count to 0 when
|
||||
// the number of consecutive taps exceeds 3. For example on Debian Linux
|
||||
// with GTK, when going past a triple click, on the fourth click the
|
||||
// selection is moved to the precise click position, on the fifth click
|
||||
// the word at the position is selected, and on the sixth click the
|
||||
// paragraph at the position is selected.
|
||||
return rawCount <= 3
|
||||
? rawCount
|
||||
: (rawCount % 3 == 0 ? 3 : rawCount % 3);
|
||||
case TargetPlatform.iOS:
|
||||
case TargetPlatform.macOS:
|
||||
// From observation, these platform's either hold their tap count at 3.
|
||||
// For example on macOS, when going past a triple click, the selection
|
||||
// should be retained at the paragraph that was first selected on triple
|
||||
// click.
|
||||
return math.min(rawCount, 3);
|
||||
case TargetPlatform.windows:
|
||||
// From observation, this platform's consecutive tap actions alternate
|
||||
// between double click and triple click actions. For example, after a
|
||||
// triple click has selected a paragraph, on the next click the word at
|
||||
// the clicked position will be selected, and on the next click the
|
||||
// paragraph at the position is selected.
|
||||
return rawCount < 2 ? rawCount : 2 + rawCount % 2;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTapTrackStart() {
|
||||
widget.onTapTrackStart?.call();
|
||||
}
|
||||
|
||||
void _handleTapTrackReset() {
|
||||
widget.onTapTrackReset?.call();
|
||||
}
|
||||
|
||||
// The down handler is force-run on success of a single tap and optimistically
|
||||
// run before a long press success.
|
||||
void _handleTapDown(TapDragDownDetails details) {
|
||||
widget.onTapDown?.call(details);
|
||||
// This isn't detected as a double tap gesture in the gesture recognizer
|
||||
// because it's 2 single taps, each of which may do different things depending
|
||||
// on whether it's a single tap, the first tap of a double tap, the second
|
||||
// tap held down, a clean double tap etc.
|
||||
if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 2) {
|
||||
return widget.onDoubleTapDown?.call(details);
|
||||
}
|
||||
|
||||
if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 3) {
|
||||
return widget.onTripleTapDown?.call(details);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTapUp(TapDragUpDetails details) {
|
||||
if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 1) {
|
||||
widget.onSingleTapUp?.call(details);
|
||||
widget.onUserTap?.call();
|
||||
} else if (widget.onUserTapAlwaysCalled) {
|
||||
widget.onUserTap?.call();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTapCancel() {
|
||||
widget.onSingleTapCancel?.call();
|
||||
}
|
||||
|
||||
void _handleDragStart(TapDragStartDetails details) {
|
||||
widget.onDragSelectionStart?.call(details);
|
||||
}
|
||||
|
||||
void _handleDragUpdate(TapDragUpdateDetails details) {
|
||||
widget.onDragSelectionUpdate?.call(details);
|
||||
}
|
||||
|
||||
void _handleDragEnd(TapDragEndDetails details) {
|
||||
widget.onDragSelectionEnd?.call(details);
|
||||
}
|
||||
|
||||
void _forcePressStarted(ForcePressDetails details) {
|
||||
widget.onForcePressStart?.call(details);
|
||||
}
|
||||
|
||||
void _forcePressEnded(ForcePressDetails details) {
|
||||
widget.onForcePressEnd?.call(details);
|
||||
}
|
||||
|
||||
void _handleLongPressStart(LongPressStartDetails details) {
|
||||
widget.onSingleLongTapStart?.call(details);
|
||||
}
|
||||
|
||||
void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
|
||||
widget.onSingleLongTapMoveUpdate?.call(details);
|
||||
}
|
||||
|
||||
void _handleLongPressEnd(LongPressEndDetails details) {
|
||||
widget.onSingleLongTapEnd?.call(details);
|
||||
}
|
||||
|
||||
void _handleLongPressCancel() {
|
||||
widget.onSingleLongTapCancel?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Map<Type, GestureRecognizerFactory> gestures =
|
||||
<Type, GestureRecognizerFactory>{};
|
||||
|
||||
gestures[TapGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||
() => TapGestureRecognizer(debugOwner: this),
|
||||
(TapGestureRecognizer instance) {
|
||||
instance
|
||||
..onSecondaryTap = widget.onSecondaryTap
|
||||
..onSecondaryTapDown = widget.onSecondaryTapDown;
|
||||
},
|
||||
);
|
||||
|
||||
if (widget.onSingleLongTapStart != null ||
|
||||
widget.onSingleLongTapMoveUpdate != null ||
|
||||
widget.onSingleLongTapEnd != null ||
|
||||
widget.onSingleLongTapCancel != null) {
|
||||
gestures[LongPressGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
||||
() => LongPressGestureRecognizer(
|
||||
debugOwner: this,
|
||||
supportedDevices: <PointerDeviceKind>{PointerDeviceKind.touch},
|
||||
),
|
||||
(LongPressGestureRecognizer instance) {
|
||||
instance
|
||||
..onLongPressStart = _handleLongPressStart
|
||||
..onLongPressMoveUpdate = _handleLongPressMoveUpdate
|
||||
..onLongPressEnd = _handleLongPressEnd
|
||||
..onLongPressCancel = _handleLongPressCancel;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.onDragSelectionStart != null ||
|
||||
widget.onDragSelectionUpdate != null ||
|
||||
widget.onDragSelectionEnd != null) {
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.iOS:
|
||||
gestures[TapAndHorizontalDragGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<
|
||||
TapAndHorizontalDragGestureRecognizer
|
||||
>(
|
||||
() => TapAndHorizontalDragGestureRecognizer(debugOwner: this),
|
||||
(TapAndHorizontalDragGestureRecognizer instance) {
|
||||
instance
|
||||
// Text selection should start from the position of the first pointer
|
||||
// down event.
|
||||
..dragStartBehavior = DragStartBehavior.down
|
||||
..eagerVictoryOnDrag =
|
||||
defaultTargetPlatform != TargetPlatform.iOS
|
||||
..onTapTrackStart = _handleTapTrackStart
|
||||
..onTapTrackReset = _handleTapTrackReset
|
||||
..onTapDown = _handleTapDown
|
||||
..onDragStart = _handleDragStart
|
||||
..onDragUpdate = _handleDragUpdate
|
||||
..onDragEnd = _handleDragEnd
|
||||
..onTapUp = _handleTapUp
|
||||
..onCancel = _handleTapCancel;
|
||||
},
|
||||
);
|
||||
case TargetPlatform.linux:
|
||||
case TargetPlatform.macOS:
|
||||
case TargetPlatform.windows:
|
||||
gestures[TapAndPanGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
|
||||
() => TapAndPanGestureRecognizer(debugOwner: this),
|
||||
(TapAndPanGestureRecognizer instance) {
|
||||
instance
|
||||
// Text selection should start from the position of the first pointer
|
||||
// down event.
|
||||
..dragStartBehavior = DragStartBehavior.down
|
||||
..onTapTrackStart = _handleTapTrackStart
|
||||
..onTapTrackReset = _handleTapTrackReset
|
||||
..onTapDown = _handleTapDown
|
||||
..onDragStart = _handleDragStart
|
||||
..onDragUpdate = _handleDragUpdate
|
||||
..onDragEnd = _handleDragEnd
|
||||
..onTapUp = _handleTapUp
|
||||
..onCancel = _handleTapCancel;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.onForcePressStart != null || widget.onForcePressEnd != null) {
|
||||
gestures[ForcePressGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<ForcePressGestureRecognizer>(
|
||||
() => ForcePressGestureRecognizer(debugOwner: this),
|
||||
(ForcePressGestureRecognizer instance) {
|
||||
instance
|
||||
..onStart = widget.onForcePressStart != null
|
||||
? _forcePressStarted
|
||||
: null
|
||||
..onEnd = widget.onForcePressEnd != null
|
||||
? _forcePressEnded
|
||||
: null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return RawGestureDetector(
|
||||
gestures: gestures,
|
||||
excludeFromSemantics: true,
|
||||
behavior: widget.behavior,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
enum SkipType {
|
||||
import 'package:PiliPlus/models/common/enum_with_label.dart';
|
||||
|
||||
enum SkipType implements EnumWithLabel {
|
||||
alwaysSkip('总是跳过'),
|
||||
skipOnce('跳过一次'),
|
||||
skipManually('手动跳过'),
|
||||
@@ -6,6 +8,7 @@ enum SkipType {
|
||||
disable('禁用')
|
||||
;
|
||||
|
||||
final String title;
|
||||
const SkipType(this.title);
|
||||
@override
|
||||
final String label;
|
||||
const SkipType(this.label);
|
||||
}
|
||||
|
||||
@@ -40,12 +40,12 @@ mixin CommonSlideMixin<T extends CommonSlidePage> on State<T>, TickerProvider {
|
||||
final isRTL = dx >= _maxWidth - offset;
|
||||
if (isLTR || isRTL) {
|
||||
_isRTL = isRTL;
|
||||
_downDx = dx;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
)
|
||||
..onStart = _onDragStart
|
||||
..onUpdate = _onDragUpdate
|
||||
..onEnd = _onDragEnd
|
||||
..onCancel = _onDragEnd;
|
||||
@@ -91,6 +91,7 @@ mixin CommonSlideMixin<T extends CommonSlidePage> on State<T>, TickerProvider {
|
||||
Widget buildList(ThemeData theme) => throw UnimplementedError();
|
||||
|
||||
void _onDragEnd([_]) {
|
||||
if (_downDx == null) return;
|
||||
final dx = _downDx!;
|
||||
if (_animController.value * _maxWidth + (_isRTL ? (_maxWidth - dx) : dx) >=
|
||||
100) {
|
||||
@@ -101,6 +102,10 @@ mixin CommonSlideMixin<T extends CommonSlidePage> on State<T>, TickerProvider {
|
||||
_downDx = null;
|
||||
}
|
||||
|
||||
void _onDragStart(DragStartDetails details) {
|
||||
_downDx = details.localPosition.dx;
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails details) {
|
||||
final from = _downDx!;
|
||||
final to = details.localPosition.dx;
|
||||
|
||||
@@ -134,54 +134,12 @@ List<SettingsModel> get extraSettings => [
|
||||
],
|
||||
),
|
||||
),
|
||||
NormalModel(
|
||||
leading: const Icon(MdiIcons.debugStepOver),
|
||||
getPopupMenuModel(
|
||||
title: '番剧片头/片尾跳过类型',
|
||||
getTrailing: () => Builder(
|
||||
builder: (context) {
|
||||
final pgcSkipType = Pref.pgcSkipType;
|
||||
final colorScheme = ColorScheme.of(context);
|
||||
final color = pgcSkipType == SkipType.disable
|
||||
? colorScheme.outline
|
||||
: colorScheme.secondary;
|
||||
return PopupMenuButton<SkipType>(
|
||||
initialValue: pgcSkipType,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text.rich(
|
||||
style: TextStyle(fontSize: 14, height: 1, color: color),
|
||||
strutStyle: const StrutStyle(
|
||||
leading: 0,
|
||||
height: 1,
|
||||
fontSize: 14,
|
||||
),
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: pgcSkipType.title),
|
||||
WidgetSpan(
|
||||
alignment: .middle,
|
||||
child: Icon(
|
||||
MdiIcons.unfoldMoreHorizontal,
|
||||
size: 14,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
onSelected: (value) async {
|
||||
await GStorage.setting.put(SettingBoxKey.pgcSkipType, value.index);
|
||||
if (context.mounted) {
|
||||
(context as Element).markNeedsBuild();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => SkipType.values
|
||||
.map((e) => PopupMenuItem(value: e, child: Text(e.title)))
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
leading: const Icon(MdiIcons.debugStepOver),
|
||||
key: SettingBoxKey.pgcSkipType,
|
||||
values: SkipType.values,
|
||||
defaultIndex: SkipType.skipOnce.index,
|
||||
),
|
||||
SwitchModel(
|
||||
title: '检查未读动态',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:PiliPlus/common/constants.dart';
|
||||
import 'package:PiliPlus/models/common/enum_with_label.dart';
|
||||
import 'package:PiliPlus/pages/setting/widgets/normal_item.dart';
|
||||
import 'package:PiliPlus/pages/setting/widgets/select_dialog.dart';
|
||||
import 'package:PiliPlus/pages/setting/widgets/switch_item.dart';
|
||||
@@ -7,6 +8,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show FilteringTextInputFormatter;
|
||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||
|
||||
@immutable
|
||||
sealed class SettingsModel {
|
||||
@@ -258,3 +260,61 @@ SettingsModel getVideoFilterSelectModel({
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
SettingsModel getPopupMenuModel({
|
||||
required String title,
|
||||
Widget? leading,
|
||||
String? subtitle,
|
||||
required String key,
|
||||
required List<EnumWithLabel> values,
|
||||
int defaultIndex = 0,
|
||||
}) {
|
||||
// final globalKey = GlobalKey<PopupMenuButtonState<EnumWithLabel>>();
|
||||
return NormalModel(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
leading: leading,
|
||||
// onTap: (context, setState) => globalKey.currentState?.showButtonMenu(),
|
||||
getTrailing: () => Builder(
|
||||
builder: (context) {
|
||||
final color = ColorScheme.of(context).secondary;
|
||||
final v = values[GStorage.setting.get(key, defaultValue: defaultIndex)];
|
||||
return PopupMenuButton(
|
||||
// key: globalKey,
|
||||
padding: .zero,
|
||||
initialValue: v,
|
||||
onSelected: (value) async {
|
||||
await GStorage.setting.put(key, value.index);
|
||||
if (context.mounted) {
|
||||
(context as Element).markNeedsBuild();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => values
|
||||
.map((i) => PopupMenuItem(value: i, child: Text(i.label)))
|
||||
.toList(),
|
||||
child: Padding(
|
||||
padding: const .symmetric(vertical: 8),
|
||||
child: Text.rich(
|
||||
style: TextStyle(fontSize: 14, height: 1, color: color),
|
||||
strutStyle: const StrutStyle(leading: 0, height: 1, fontSize: 14),
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: v.label),
|
||||
WidgetSpan(
|
||||
alignment: .middle,
|
||||
child: Icon(
|
||||
size: 14,
|
||||
MdiIcons.unfoldMoreHorizontal,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
style: TextStyle(color: color),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,13 @@ import 'package:PiliPlus/models/common/video/video_quality.dart';
|
||||
import 'package:PiliPlus/pages/setting/models/model.dart';
|
||||
import 'package:PiliPlus/pages/setting/widgets/ordered_multi_select_dialog.dart';
|
||||
import 'package:PiliPlus/pages/setting/widgets/select_dialog.dart';
|
||||
import 'package:PiliPlus/plugin/pl_player/models/audio_output_type.dart';
|
||||
import 'package:PiliPlus/plugin/pl_player/models/hwdec_type.dart';
|
||||
import 'package:PiliPlus/utils/storage.dart';
|
||||
import 'package:PiliPlus/utils/storage_key.dart';
|
||||
import 'package:PiliPlus/utils/storage_pref.dart';
|
||||
import 'package:PiliPlus/utils/video_utils.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||
@@ -317,13 +319,32 @@ List<SettingsModel> get videoSettings => [
|
||||
}
|
||||
},
|
||||
),
|
||||
if (Platform.isAndroid)
|
||||
const SwitchModel(
|
||||
title: '优先使用 OpenSL ES 输出音频',
|
||||
leading: Icon(Icons.speaker_outlined),
|
||||
subtitle: '关闭则优先使用AAudio输出音频(此项即mpv的--ao),若遇系统音效丢失、无声、音画不同步等问题请尝试打开。',
|
||||
setKey: SettingBoxKey.useOpenSLES,
|
||||
defaultVal: false,
|
||||
if (kDebugMode || Platform.isAndroid)
|
||||
NormalModel(
|
||||
title: '音频输出设备',
|
||||
leading: const Icon(Icons.speaker_outlined),
|
||||
getSubtitle: () => '当前:${Pref.audioOutput}',
|
||||
onTap: (context, setState) async {
|
||||
final result = await showDialog<List<String>>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return OrderedMultiSelectDialog<String>(
|
||||
title: '音频输出设备',
|
||||
initValues: Pref.audioOutput.split(','),
|
||||
values: {
|
||||
for (final e in AudioOutput.values) e.name: e.label,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
if (result != null && result.isNotEmpty) {
|
||||
await GStorage.setting.put(
|
||||
SettingBoxKey.audioOutput,
|
||||
result.join(','),
|
||||
);
|
||||
setState();
|
||||
}
|
||||
},
|
||||
),
|
||||
const SwitchModel(
|
||||
title: '扩大缓冲区',
|
||||
|
||||
@@ -594,7 +594,7 @@ class _SponsorBlockPageState extends State<SponsorBlockPage> {
|
||||
.map(
|
||||
(item) => PopupMenuItem<SkipType>(
|
||||
value: item,
|
||||
child: Text(item.title),
|
||||
child: Text(item.label),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
@@ -617,7 +617,7 @@ class _SponsorBlockPageState extends State<SponsorBlockPage> {
|
||||
),
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: item.second.title),
|
||||
TextSpan(text: item.second.label),
|
||||
WidgetSpan(
|
||||
alignment: .middle,
|
||||
child: Icon(
|
||||
|
||||
@@ -132,6 +132,10 @@ class VideoDetailController extends GetxController
|
||||
String? audioUrl;
|
||||
Duration? defaultST;
|
||||
Duration? playedTime;
|
||||
String get playedTimePos {
|
||||
final pos = playedTime?.inMilliseconds;
|
||||
return pos == null || pos == 0 ? '' : '?t=${pos / 1000}';
|
||||
}
|
||||
|
||||
// 亮度
|
||||
double? brightness;
|
||||
@@ -659,7 +663,7 @@ class VideoDetailController extends GetxController
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.skipType.title,
|
||||
item.skipType.label,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
if (item.segment.second != 0)
|
||||
|
||||
@@ -143,7 +143,8 @@ class PgcIntroController extends CommonIntroController {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) {
|
||||
String videoUrl = '${HttpString.baseUrl}/bangumi/play/ep$epId';
|
||||
String videoUrl =
|
||||
'${HttpString.baseUrl}/bangumi/play/ep$epId${videoDetailCtr.playedTimePos}';
|
||||
return AlertDialog(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||
|
||||
@@ -302,7 +302,8 @@ class UgcIntroController extends CommonIntroController with ReloadMixin {
|
||||
context: context,
|
||||
builder: (_) {
|
||||
final videoDetail = this.videoDetail.value;
|
||||
String videoUrl = '${HttpString.baseUrl}/video/$bvid';
|
||||
String videoUrl =
|
||||
'${HttpString.baseUrl}/video/$bvid${videoDetailCtr.playedTimePos}';
|
||||
return AlertDialog(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:PiliPlus/common/constants.dart';
|
||||
import 'package:PiliPlus/common/widgets/dialog/dialog.dart';
|
||||
import 'package:PiliPlus/common/widgets/flutter/text_intro/text.dart';
|
||||
import 'package:PiliPlus/common/widgets/gesture/tap_gesture_recognizer.dart';
|
||||
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
|
||||
import 'package:PiliPlus/common/widgets/pendant_avatar.dart';
|
||||
@@ -19,7 +20,6 @@ import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart';
|
||||
import 'package:PiliPlus/pages/video/introduction/ugc/widgets/action_item.dart';
|
||||
import 'package:PiliPlus/pages/video/introduction/ugc/widgets/page.dart';
|
||||
import 'package:PiliPlus/pages/video/introduction/ugc/widgets/season.dart';
|
||||
import 'package:PiliPlus/pages/video/introduction/ugc/widgets/selectable_text.dart';
|
||||
import 'package:PiliPlus/utils/app_scheme.dart';
|
||||
import 'package:PiliPlus/utils/date_utils.dart';
|
||||
import 'package:PiliPlus/utils/duration_utils.dart';
|
||||
|
||||
@@ -19,22 +19,3 @@ Widget selectableText(
|
||||
scrollPhysics: const NeverScrollableScrollPhysics(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget selectableRichText(
|
||||
TextSpan textSpan, {
|
||||
TextStyle? style,
|
||||
}) {
|
||||
if (PlatformUtils.isDesktop) {
|
||||
return SelectionArea(
|
||||
child: Text.rich(
|
||||
style: style,
|
||||
textSpan,
|
||||
),
|
||||
);
|
||||
}
|
||||
return SelectableText.rich(
|
||||
style: style,
|
||||
textSpan,
|
||||
scrollPhysics: const NeverScrollableScrollPhysics(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,6 @@ class ReplyItemGrpc extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
|
||||
final isMobile = PlatformUtils.isMobile;
|
||||
void showMore() => showModalBottomSheet(
|
||||
context: context,
|
||||
useSafeArea: true,
|
||||
@@ -101,63 +100,63 @@ class ReplyItemGrpc extends StatelessWidget {
|
||||
child: InkWell(
|
||||
onTap: () => replyReply?.call(replyItem, null),
|
||||
onLongPress: showMore,
|
||||
onSecondaryTap: isMobile ? null : showMore,
|
||||
onSecondaryTap: PlatformUtils.isMobile ? null : showMore,
|
||||
child: _buildContent(context, theme),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAuthorPanel(BuildContext context, ThemeData theme) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 14, 8, 5),
|
||||
child: content(context, theme),
|
||||
);
|
||||
|
||||
Widget _buildContent(BuildContext context, ThemeData theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (PendantAvatar.showDynDecorate &&
|
||||
replyItem.member.hasGarbCardImage())
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 12,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.centerRight,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
height: 38,
|
||||
memCacheHeight: 38.cacheSize(context),
|
||||
imageUrl: ImageUtils.safeThumbnailUrl(
|
||||
replyItem.member.garbCardImage,
|
||||
),
|
||||
placeholder: (_, _) => const SizedBox.shrink(),
|
||||
),
|
||||
if (replyItem.member.hasGarbCardNumber())
|
||||
Text(
|
||||
'NO.\n${replyItem.member.garbCardNumber}',
|
||||
style: TextStyle(
|
||||
fontSize: 8,
|
||||
fontFamily: 'digital_id_num',
|
||||
color:
|
||||
replyItem.member.garbCardFanColor.startsWith('#')
|
||||
? Utils.parseColor(
|
||||
replyItem.member.garbCardFanColor,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
Widget child = Padding(
|
||||
padding: const .fromLTRB(12, 14, 8, 5),
|
||||
child: content(context, theme),
|
||||
);
|
||||
const double top = 8.0;
|
||||
const double right = 12.0;
|
||||
const double height = 38.0;
|
||||
if (PendantAvatar.showDynDecorate && replyItem.member.hasGarbCardImage()) {
|
||||
child = Stack(
|
||||
clipBehavior: .none,
|
||||
children: [
|
||||
child,
|
||||
Positioned(
|
||||
top: top,
|
||||
right: right,
|
||||
height: height,
|
||||
child: CachedNetworkImage(
|
||||
height: height,
|
||||
memCacheHeight: height.cacheSize(context),
|
||||
imageUrl: ImageUtils.safeThumbnailUrl(
|
||||
replyItem.member.garbCardImage,
|
||||
),
|
||||
placeholder: (_, _) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
if (replyItem.member.hasGarbCardNumber())
|
||||
Positioned(
|
||||
top: top,
|
||||
right: right,
|
||||
height: height,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'NO.\n${replyItem.member.garbCardNumber}',
|
||||
style: TextStyle(
|
||||
fontSize: 8,
|
||||
fontFamily: 'digital_id_num',
|
||||
color: replyItem.member.garbCardFanColor.startsWith('#')
|
||||
? Utils.parseColor(replyItem.member.garbCardFanColor)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildAuthorPanel(context, theme),
|
||||
],
|
||||
)
|
||||
else
|
||||
_buildAuthorPanel(context, theme),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: .stretch,
|
||||
children: [
|
||||
child,
|
||||
if (needDivider)
|
||||
Divider(
|
||||
indent: 55,
|
||||
@@ -169,25 +168,11 @@ class ReplyItemGrpc extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar() => PendantAvatar(
|
||||
avatar: replyItem.member.face,
|
||||
size: 34,
|
||||
badgeSize: 14,
|
||||
isVip: replyItem.member.vipStatus > 0,
|
||||
officialType: replyItem.member.officialVerifyType.toInt(),
|
||||
garbPendantImage: replyItem.member.hasGarbPendantImage()
|
||||
? replyItem.member.garbPendantImage
|
||||
: null,
|
||||
);
|
||||
|
||||
Widget content(BuildContext context, ThemeData theme) {
|
||||
final padding = EdgeInsets.only(
|
||||
left: replyLevel == 0 ? 6 : 45,
|
||||
right: 6,
|
||||
);
|
||||
final padding = EdgeInsets.only(left: replyLevel == 0 ? 6 : 45, right: 6);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
@@ -196,11 +181,20 @@ class ReplyItemGrpc extends StatelessWidget {
|
||||
Get.toNamed('/member?mid=${replyItem.mid}');
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .center,
|
||||
spacing: 12,
|
||||
children: [
|
||||
_buildAvatar(),
|
||||
PendantAvatar(
|
||||
avatar: replyItem.member.face,
|
||||
size: 34,
|
||||
badgeSize: 14,
|
||||
isVip: replyItem.member.vipStatus > 0,
|
||||
officialType: replyItem.member.officialVerifyType.toInt(),
|
||||
garbPendantImage: replyItem.member.hasGarbPendantImage()
|
||||
? replyItem.member.garbPendantImage
|
||||
: null,
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -1344,13 +1344,13 @@ class _VideoDetailPageVState extends State<VideoDetailPageV>
|
||||
required double height,
|
||||
bool isPipMode = false,
|
||||
}) => PopScope(
|
||||
key: videoDetailController.videoPlayerKey,
|
||||
canPop:
|
||||
!isFullScreen &&
|
||||
!videoDetailController.plPlayerController.isDesktopPip &&
|
||||
(videoDetailController.horizontalScreen || isPortrait),
|
||||
onPopInvokedWithResult: _onPopInvokedWithResult,
|
||||
child: Obx(
|
||||
key: videoDetailController.videoPlayerKey,
|
||||
() =>
|
||||
videoDetailController.videoState.value is! Success ||
|
||||
!videoDetailController.autoPlay.value ||
|
||||
|
||||
@@ -796,8 +796,7 @@ class PlPlayerController {
|
||||
await pp.setProperty("af", "scaletempo2=max-speed=8");
|
||||
if (Platform.isAndroid) {
|
||||
await pp.setProperty("volume-max", "100");
|
||||
final ao = Pref.useOpenSLES ? "opensles,aaudio" : "aaudio,opensles";
|
||||
await pp.setProperty("ao", ao);
|
||||
await pp.setProperty("ao", Pref.audioOutput);
|
||||
}
|
||||
// video-sync=display-resample
|
||||
await pp.setProperty("video-sync", Pref.videoSync);
|
||||
|
||||
14
lib/plugin/pl_player/models/audio_output_type.dart
Normal file
14
lib/plugin/pl_player/models/audio_output_type.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:PiliPlus/models/common/enum_with_label.dart';
|
||||
|
||||
enum AudioOutput implements EnumWithLabel {
|
||||
opensles('OpenSL ES'),
|
||||
aaudio('AAudio'),
|
||||
audiotrack('AudioTrack')
|
||||
;
|
||||
|
||||
static final defaultValue = values.map((e) => e.name).join(',');
|
||||
|
||||
@override
|
||||
final String label;
|
||||
const AudioOutput(this.label);
|
||||
}
|
||||
@@ -41,6 +41,18 @@ abstract final class PiliScheme {
|
||||
listener = appLinks.uriLinkStream.listen(routePush);
|
||||
}
|
||||
|
||||
static int? _videoProgress(Map<String, String> queryParameters) {
|
||||
if ((queryParameters['start_progress'] ?? queryParameters['dm_progress'])
|
||||
case final p?) {
|
||||
return int.tryParse(p);
|
||||
} else if (queryParameters['t'] case final t0?) {
|
||||
if (double.tryParse(t0) case final t1?) {
|
||||
return (t1 * 1000).toInt();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<bool> routePushFromUrl(
|
||||
String url, {
|
||||
bool selfHandle = false,
|
||||
@@ -99,7 +111,7 @@ abstract final class PiliScheme {
|
||||
PageUtils.viewPgc(
|
||||
seasonId: isEp ? null : id,
|
||||
epId: isEp ? id : null,
|
||||
progress: uri.queryParameters['start_progress'],
|
||||
progress: _videoProgress(uri.queryParameters),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -121,7 +133,6 @@ abstract final class PiliScheme {
|
||||
// bilibili://video/{aid}/?comment_root_id=***&comment_secondary_id=***
|
||||
final queryParameters = uri.queryParameters;
|
||||
if (queryParameters['comment_root_id'] != null) {
|
||||
// to check
|
||||
// to video reply
|
||||
String? oid = uriDigitRegExp.firstMatch(path)?.group(1);
|
||||
int? rpid = int.tryParse(queryParameters['comment_root_id']!);
|
||||
@@ -146,11 +157,10 @@ abstract final class PiliScheme {
|
||||
final cid = queryParameters['cid'];
|
||||
if (cid != null) {
|
||||
bvid ??= IdUtils.av2bv(int.parse(aid!));
|
||||
final progress = queryParameters['dm_progress'];
|
||||
PageUtils.toVideoPage(
|
||||
bvid: bvid,
|
||||
cid: int.parse(cid),
|
||||
progress: progress == null ? null : int.parse(progress),
|
||||
progress: _videoProgress(queryParameters),
|
||||
off: off,
|
||||
);
|
||||
} else {
|
||||
@@ -158,7 +168,7 @@ abstract final class PiliScheme {
|
||||
aid != null ? int.parse(aid) : null,
|
||||
bvid,
|
||||
off: off,
|
||||
progress: queryParameters['dm_progress'],
|
||||
progress: _videoProgress(queryParameters),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
@@ -629,12 +639,9 @@ abstract final class PiliScheme {
|
||||
case 'bangumi':
|
||||
// www.bilibili.com/bangumi/play/ep{eid}?start_progress={offset}&thumb_up_dm_id={dmid}
|
||||
// if (kDebugMode) debugPrint('番剧');
|
||||
final queryParameters = uri.queryParameters;
|
||||
bool hasMatch = PageUtils.viewPgcFromUri(
|
||||
path,
|
||||
progress:
|
||||
queryParameters['start_progress'] ??
|
||||
queryParameters['dm_progress'],
|
||||
progress: _videoProgress(uri.queryParameters),
|
||||
);
|
||||
if (hasMatch) {
|
||||
return true;
|
||||
@@ -662,7 +669,7 @@ abstract final class PiliScheme {
|
||||
res.av,
|
||||
res.bv,
|
||||
off: off,
|
||||
progress: queryParameters['dm_progress'],
|
||||
progress: _videoProgress(queryParameters),
|
||||
part: part,
|
||||
);
|
||||
return true;
|
||||
@@ -850,7 +857,7 @@ abstract final class PiliScheme {
|
||||
String? bvid, {
|
||||
bool showDialog = true,
|
||||
bool off = false,
|
||||
String? progress,
|
||||
int? progress, // milliseconds
|
||||
String? part,
|
||||
}) async {
|
||||
try {
|
||||
@@ -872,7 +879,7 @@ abstract final class PiliScheme {
|
||||
aid: aid,
|
||||
bvid: bvid,
|
||||
cid: cid,
|
||||
progress: progress == null ? null : int.parse(progress),
|
||||
progress: progress,
|
||||
off: off,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -736,7 +736,7 @@ abstract final class PageUtils {
|
||||
int? pgcType,
|
||||
String? cover,
|
||||
String? title,
|
||||
int? progress,
|
||||
int? progress, // milliseconds
|
||||
Map? extraArguments,
|
||||
bool off = false,
|
||||
}) {
|
||||
@@ -773,7 +773,7 @@ abstract final class PageUtils {
|
||||
static bool viewPgcFromUri(
|
||||
String uri, {
|
||||
bool isPgc = true,
|
||||
String? progress,
|
||||
int? progress, // milliseconds
|
||||
int? aid,
|
||||
}) {
|
||||
RegExpMatch? match = _pgcRegex.firstMatch(uri);
|
||||
@@ -817,7 +817,7 @@ abstract final class PageUtils {
|
||||
static Future<void> viewPgc({
|
||||
dynamic seasonId,
|
||||
dynamic epId,
|
||||
String? progress,
|
||||
int? progress, // milliseconds
|
||||
}) async {
|
||||
try {
|
||||
SmartDialog.showLoading(msg: '资源获取中');
|
||||
@@ -837,7 +837,7 @@ abstract final class PageUtils {
|
||||
seasonId: response.seasonId,
|
||||
epId: episode.epId,
|
||||
cover: episode.cover,
|
||||
progress: progress == null ? null : int.tryParse(progress),
|
||||
progress: progress,
|
||||
extraArguments: {
|
||||
'pgcApi': true,
|
||||
'pgcItem': response,
|
||||
@@ -886,7 +886,7 @@ abstract final class PageUtils {
|
||||
epId: episode.epId,
|
||||
pgcType: response.type,
|
||||
cover: episode.cover,
|
||||
progress: progress == null ? null : int.tryParse(progress),
|
||||
progress: progress,
|
||||
extraArguments: {
|
||||
'pgcItem': response,
|
||||
},
|
||||
|
||||
@@ -98,46 +98,47 @@ abstract final class ReplyUtils {
|
||||
await Future.delayed(const Duration(seconds: 8));
|
||||
}
|
||||
void showReplyCheckResult(String message, {bool isBan = false}) {
|
||||
final actions = [
|
||||
if (isBan)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
String? uri;
|
||||
switch (type) {
|
||||
case 1:
|
||||
uri = IdUtils.av2bv(oid);
|
||||
case 17:
|
||||
uri = 'https://www.bilibili.com/opus/$oid';
|
||||
}
|
||||
if (uri != null) {
|
||||
Utils.copyText(uri);
|
||||
}
|
||||
Get.toNamed(
|
||||
'/webview',
|
||||
parameters: {
|
||||
'url':
|
||||
'https://www.bilibili.com/h5/comment/appeal?${Utils.themeUrl(Get.isDarkMode)}',
|
||||
},
|
||||
);
|
||||
},
|
||||
child: const Text('申诉'),
|
||||
),
|
||||
if (!isManual)
|
||||
TextButton(
|
||||
onPressed: Get.back,
|
||||
child: Text(
|
||||
'关闭',
|
||||
style: TextStyle(color: Get.theme.colorScheme.outline),
|
||||
),
|
||||
),
|
||||
];
|
||||
showDialog(
|
||||
context: Get.context!,
|
||||
barrierDismissible: isManual,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('评论检查结果'),
|
||||
content: SelectableText(message),
|
||||
actions: [
|
||||
if (isBan)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
String? uri;
|
||||
switch (type) {
|
||||
case 1:
|
||||
uri = IdUtils.av2bv(oid);
|
||||
case 17:
|
||||
uri = 'https://www.bilibili.com/opus/$oid';
|
||||
}
|
||||
if (uri != null) {
|
||||
Utils.copyText(uri);
|
||||
}
|
||||
Get.toNamed(
|
||||
'/webview',
|
||||
parameters: {
|
||||
'url':
|
||||
'https://www.bilibili.com/h5/comment/appeal?${Utils.themeUrl(Get.isDarkMode)}',
|
||||
},
|
||||
);
|
||||
},
|
||||
child: const Text('申诉'),
|
||||
),
|
||||
if (!isManual)
|
||||
TextButton(
|
||||
onPressed: Get.back,
|
||||
child: Text(
|
||||
'关闭',
|
||||
style: TextStyle(color: Get.theme.colorScheme.outline),
|
||||
),
|
||||
),
|
||||
],
|
||||
actions: actions.isEmpty ? null : actions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -316,6 +316,31 @@ abstract final class RequestUtils {
|
||||
clearCookie: true,
|
||||
);
|
||||
final isSuccess = res.isSuccess;
|
||||
final actions = [
|
||||
if (!isSuccess)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
Utils.copyText('https://www.bilibili.com/opus/$id');
|
||||
Get.toNamed(
|
||||
'/webview',
|
||||
parameters: {
|
||||
'url':
|
||||
'https://www.bilibili.com/h5/comment/appeal?${Utils.themeUrl(Get.isDarkMode)}',
|
||||
},
|
||||
);
|
||||
},
|
||||
child: const Text('申诉'),
|
||||
),
|
||||
if (!isManual)
|
||||
TextButton(
|
||||
onPressed: Get.back,
|
||||
child: Text(
|
||||
'关闭',
|
||||
style: TextStyle(color: Get.theme.colorScheme.outline),
|
||||
),
|
||||
),
|
||||
];
|
||||
showDialog(
|
||||
context: Get.context!,
|
||||
barrierDismissible: isManual,
|
||||
@@ -324,31 +349,7 @@ abstract final class RequestUtils {
|
||||
content: SelectableText(
|
||||
'${isSuccess ? '无账号状态下找到了你的动态,动态正常!' : '你的动态被shadow ban(仅自己可见)!'}${dynText != null ? ' \n\n动态内容: $dynText' : ''}',
|
||||
),
|
||||
actions: [
|
||||
if (!isSuccess)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
Utils.copyText('https://www.bilibili.com/opus/$id');
|
||||
Get.toNamed(
|
||||
'/webview',
|
||||
parameters: {
|
||||
'url':
|
||||
'https://www.bilibili.com/h5/comment/appeal?${Utils.themeUrl(Get.isDarkMode)}',
|
||||
},
|
||||
);
|
||||
},
|
||||
child: const Text('申诉'),
|
||||
),
|
||||
if (!isManual)
|
||||
TextButton(
|
||||
onPressed: Get.back,
|
||||
child: Text(
|
||||
'关闭',
|
||||
style: TextStyle(color: Get.theme.colorScheme.outline),
|
||||
),
|
||||
),
|
||||
],
|
||||
actions: actions.isEmpty ? null : actions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ abstract final class SettingBoxKey {
|
||||
defaultToastOp = 'defaultToastOp',
|
||||
defaultPicQa = 'defaultPicQa',
|
||||
enableHA = 'enableHA',
|
||||
useOpenSLES = 'useOpenSLES',
|
||||
audioOutput = 'audioOutput',
|
||||
expandBuffer = 'expandBuffer',
|
||||
hardwareDecoding = 'hardwareDecoding',
|
||||
videoSync = 'videoSync',
|
||||
|
||||
@@ -24,6 +24,7 @@ import 'package:PiliPlus/models/common/video/video_decode_type.dart';
|
||||
import 'package:PiliPlus/models/common/video/video_quality.dart';
|
||||
import 'package:PiliPlus/models/user/danmaku_rule.dart';
|
||||
import 'package:PiliPlus/models/user/info.dart';
|
||||
import 'package:PiliPlus/plugin/pl_player/models/audio_output_type.dart';
|
||||
import 'package:PiliPlus/plugin/pl_player/models/bottom_progress_behavior.dart';
|
||||
import 'package:PiliPlus/plugin/pl_player/models/fullscreen_mode.dart';
|
||||
import 'package:PiliPlus/plugin/pl_player/models/hwdec_type.dart';
|
||||
@@ -786,8 +787,10 @@ abstract final class Pref {
|
||||
static bool get expandBuffer =>
|
||||
_setting.get(SettingBoxKey.expandBuffer, defaultValue: false);
|
||||
|
||||
static bool get useOpenSLES =>
|
||||
_setting.get(SettingBoxKey.useOpenSLES, defaultValue: false);
|
||||
static String get audioOutput => _setting.get(
|
||||
SettingBoxKey.audioOutput,
|
||||
defaultValue: AudioOutput.defaultValue,
|
||||
);
|
||||
|
||||
static bool get enableAi =>
|
||||
_setting.get(SettingBoxKey.enableAi, defaultValue: false);
|
||||
|
||||
22
pubspec.lock
22
pubspec.lock
@@ -416,10 +416,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
|
||||
sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.0"
|
||||
version: "5.9.1"
|
||||
dio_http2_adapter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -529,11 +529,11 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: mod
|
||||
resolved-ref: e9f51575d0103880f4d0ad64f314bc57d0b49cf1
|
||||
ref: "v10.3.10"
|
||||
resolved-ref: fd8afd89260436b63b9d41e150b4eb3c806cc8fd
|
||||
url: "https://github.com/bggRGjQaUbCoE/flutter_file_picker.git"
|
||||
source: git
|
||||
version: "10.3.8"
|
||||
version: "10.3.10"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -945,10 +945,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_android
|
||||
sha256: "297e42bd236c4ac4b091d4277292159b3280545e030cae2be3d503f9ecf7e6a1"
|
||||
sha256: "518a16108529fc18657a3e6dde4a043dc465d16596d20ab2abd49a4cac2e703d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.13+12"
|
||||
version: "0.8.13+13"
|
||||
image_picker_for_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1480,10 +1480,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: safe_local_storage
|
||||
sha256: "608354d4cdfdabb29428bdb330c4e54dbc31aab238b09ba59fe73660580553cc"
|
||||
sha256: "287ea1f667c0b93cdc127dccc707158e2d81ee59fba0459c31a0c7da4d09c755"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
version: "2.0.3"
|
||||
saver_gallery:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1592,10 +1592,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc"
|
||||
sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.18"
|
||||
version: "2.4.20"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -47,7 +47,7 @@ dependencies:
|
||||
ref: version_4.7.2
|
||||
|
||||
# 网络
|
||||
dio: ^5.7.0
|
||||
dio: ^5.9.1
|
||||
cookie_jar: ^4.0.8
|
||||
connectivity_plus: ^7.0.0
|
||||
dio_http2_adapter: ^2.5.3
|
||||
@@ -218,7 +218,7 @@ dependencies:
|
||||
file_picker:
|
||||
git:
|
||||
url: https://github.com/bggRGjQaUbCoE/flutter_file_picker.git
|
||||
ref: mod
|
||||
ref: v10.3.10
|
||||
super_sliver_list:
|
||||
git:
|
||||
url: https://github.com/bggRGjQaUbCoE/super_sliver_list.git
|
||||
|
||||
Reference in New Issue
Block a user