opt show more text

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-06-26 10:15:08 +08:00
parent 0b95476d8f
commit 2eb86658b7
11 changed files with 5464 additions and 385 deletions

View File

@@ -6,6 +6,10 @@ class StyleString {
static const BorderRadius mdRadius = BorderRadius.all(imgRadius); static const BorderRadius mdRadius = BorderRadius.all(imgRadius);
static const Radius imgRadius = Radius.circular(10); static const Radius imgRadius = Radius.circular(10);
static const double aspectRatio = 16 / 10; static const double aspectRatio = 16 / 10;
static const bottomSheetRadius = BorderRadius.only(
topLeft: Radius.circular(18),
topRight: Radius.circular(18),
);
} }
class Constants { class Constants {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,314 @@
import 'dart:ui' as ui;
import 'package:PiliPlus/common/widgets/text/paragraph.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' hide RenderParagraph;
/// A paragraph of rich text.
///
/// {@youtube 560 315 https://www.youtube.com/watch?v=rykDVh-QFfw}
///
/// The [RichText] widget displays text that uses multiple different styles. The
/// text to display is described using a tree of [TextSpan] objects, each of
/// which has an associated style that is used for that subtree. The text might
/// break across multiple lines or might all be displayed on the same line
/// depending on the layout constraints.
///
/// Text displayed in a [RichText] widget must be explicitly styled. When
/// picking which style to use, consider using [DefaultTextStyle.of] the current
/// [BuildContext] to provide defaults. For more details on how to style text in
/// a [RichText] widget, see the documentation for [TextStyle].
///
/// Consider using the [Text] widget to integrate with the [DefaultTextStyle]
/// automatically. When all the text uses the same style, the default constructor
/// is less verbose. The [Text.rich] constructor allows you to style multiple
/// spans with the default text style while still allowing specified styles per
/// span.
///
/// {@tool snippet}
///
/// This sample demonstrates how to mix and match text with different text
/// styles using the [RichText] Widget. It displays the text "Hello bold world,"
/// emphasizing the word "bold" using a bold font weight.
///
/// ![](https://flutter.github.io/assets-for-api-docs/assets/widgets/rich_text.png)
///
/// ```dart
/// RichText(
/// text: TextSpan(
/// text: 'Hello ',
/// style: DefaultTextStyle.of(context).style,
/// children: const <TextSpan>[
/// TextSpan(text: 'bold', style: TextStyle(fontWeight: FontWeight.bold)),
/// TextSpan(text: ' world!'),
/// ],
/// ),
/// )
/// ```
/// {@end-tool}
///
/// ## Selections
///
/// To make this [RichText] Selectable, the [RichText] needs to be in the
/// subtree of a [SelectionArea] or [SelectableRegion] and a
/// [SelectionRegistrar] needs to be assigned to the
/// [RichText.selectionRegistrar]. One can use
/// [SelectionContainer.maybeOf] to get the [SelectionRegistrar] from a
/// context. This enables users to select the text in [RichText]s with mice or
/// touch events.
///
/// The [selectionColor] also needs to be set if the selection is enabled to
/// draw the selection highlights.
///
/// {@tool snippet}
///
/// This sample demonstrates how to assign a [SelectionRegistrar] for RichTexts
/// in the SelectionArea subtree.
///
/// ![](https://flutter.github.io/assets-for-api-docs/assets/widgets/rich_text.png)
///
/// ```dart
/// RichText(
/// text: const TextSpan(text: 'Hello'),
/// selectionRegistrar: SelectionContainer.maybeOf(context),
/// selectionColor: const Color(0xAF6694e8),
/// )
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [TextStyle], which discusses how to style text.
/// * [TextSpan], which is used to describe the text in a paragraph.
/// * [Text], which automatically applies the ambient styles described by a
/// [DefaultTextStyle] to a single string.
/// * [Text.rich], a const text widget that provides similar functionality
/// as [RichText]. [Text.rich] will inherit [TextStyle] from [DefaultTextStyle].
/// * [SelectableRegion], which provides an overview of the selection system.
class RichText extends MultiChildRenderObjectWidget {
/// Creates a paragraph of rich text.
///
/// The [maxLines] property may be null (and indeed defaults to null), but if
/// it is not null, it must be greater than zero.
///
/// The [textDirection], if null, defaults to the ambient [Directionality],
/// which in that case must not be null.
RichText({
super.key,
required this.text,
this.textAlign = TextAlign.start,
this.textDirection,
this.softWrap = true,
this.overflow = TextOverflow.clip,
@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.',
)
double textScaleFactor = 1.0,
TextScaler textScaler = TextScaler.noScaling,
this.maxLines,
this.locale,
this.strutStyle,
this.textWidthBasis = TextWidthBasis.parent,
this.textHeightBehavior,
this.selectionRegistrar,
this.selectionColor,
}) : assert(maxLines == null || maxLines > 0),
assert(selectionRegistrar == null || selectionColor != null),
assert(
textScaleFactor == 1.0 || identical(textScaler, TextScaler.noScaling),
'Use textScaler instead.',
),
textScaler = _effectiveTextScalerFrom(textScaler, textScaleFactor),
super(
children: WidgetSpan.extractFromInlineSpan(
text,
_effectiveTextScalerFrom(textScaler, textScaleFactor),
),
);
static TextScaler _effectiveTextScalerFrom(
TextScaler textScaler, double textScaleFactor) {
return switch ((textScaler, textScaleFactor)) {
(final TextScaler scaler, 1.0) => scaler,
(TextScaler.noScaling, final double textScaleFactor) =>
TextScaler.linear(textScaleFactor),
(final TextScaler scaler, _) => scaler,
};
}
/// The text to display in this widget.
final InlineSpan text;
/// How the text should be aligned horizontally.
final TextAlign textAlign;
/// The directionality of the text.
///
/// This decides how [textAlign] values like [TextAlign.start] and
/// [TextAlign.end] are interpreted.
///
/// This is also used to disambiguate how to render bidirectional text. For
/// example, if the [text] is an English phrase followed by a Hebrew phrase,
/// in a [TextDirection.ltr] context the English phrase will be on the left
/// and the Hebrew phrase to its right, while in a [TextDirection.rtl]
/// context, the English phrase will be on the right and the Hebrew phrase on
/// its left.
///
/// Defaults to the ambient [Directionality], if any. If there is no ambient
/// [Directionality], then this must not be null.
final TextDirection? textDirection;
/// Whether the text should break at soft line breaks.
///
/// If false, the glyphs in the text will be positioned as if there was unlimited horizontal space.
final bool softWrap;
/// How visual overflow should be handled.
final TextOverflow overflow;
/// Deprecated. Will be removed in a future version of Flutter. Use
/// [textScaler] instead.
///
/// The number of font pixels for each logical pixel.
///
/// For example, if the text scale factor is 1.5, text will be 50% larger than
/// the specified font size.
@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.',
)
double get textScaleFactor => textScaler.textScaleFactor;
/// {@macro flutter.painting.textPainter.textScaler}
final TextScaler textScaler;
/// An optional maximum number of lines for the text to span, wrapping if necessary.
/// If the text exceeds the given number of lines, it will be truncated according
/// to [overflow].
///
/// If this is 1, text will not wrap. Otherwise, text will be wrapped at the
/// edge of the box.
final int? maxLines;
/// Used to select a font when the same Unicode character can
/// be rendered differently, depending on the locale.
///
/// It's rarely necessary to set this property. By default its value
/// is inherited from the enclosing app with `Localizations.localeOf(context)`.
///
/// See [RenderParagraph.locale] for more information.
final Locale? locale;
/// {@macro flutter.painting.textPainter.strutStyle}
final StrutStyle? strutStyle;
/// {@macro flutter.painting.textPainter.textWidthBasis}
final TextWidthBasis textWidthBasis;
/// {@macro dart.ui.textHeightBehavior}
final ui.TextHeightBehavior? textHeightBehavior;
/// The [SelectionRegistrar] this rich text is subscribed to.
///
/// If this is set, [selectionColor] must be non-null.
final SelectionRegistrar? selectionRegistrar;
/// The color to use when painting the selection.
///
/// This is ignored if [selectionRegistrar] is null.
///
/// See the section on selections in the [RichText] top-level API
/// documentation for more details on enabling selection in [RichText]
/// widgets.
final Color? selectionColor;
@override
RenderParagraph createRenderObject(BuildContext context) {
assert(textDirection != null || debugCheckHasDirectionality(context));
return RenderParagraph(
text,
textAlign: textAlign,
textDirection: textDirection ?? Directionality.of(context),
softWrap: softWrap,
overflow: overflow,
textScaler: textScaler,
maxLines: maxLines,
strutStyle: strutStyle,
textWidthBasis: textWidthBasis,
textHeightBehavior: textHeightBehavior,
locale: locale ?? Localizations.maybeLocaleOf(context),
registrar: selectionRegistrar,
selectionColor: selectionColor,
primary: Theme.of(context).colorScheme.primary,
);
}
@override
void updateRenderObject(BuildContext context, RenderParagraph renderObject) {
assert(textDirection != null || debugCheckHasDirectionality(context));
renderObject
..text = text
..textAlign = textAlign
..textDirection = textDirection ?? Directionality.of(context)
..softWrap = softWrap
..overflow = overflow
..textScaler = textScaler
..maxLines = maxLines
..strutStyle = strutStyle
..textWidthBasis = textWidthBasis
..textHeightBehavior = textHeightBehavior
..locale = locale ?? Localizations.maybeLocaleOf(context)
..registrar = selectionRegistrar
..selectionColor = selectionColor
..primary = Theme.of(context).colorScheme.primary;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(EnumProperty<TextAlign>('textAlign', textAlign,
defaultValue: TextAlign.start));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection,
defaultValue: null));
properties.add(
FlagProperty(
'softWrap',
value: softWrap,
ifTrue: 'wrapping at box width',
ifFalse: 'no wrapping except at line break characters',
showName: true,
),
);
properties.add(
EnumProperty<TextOverflow>('overflow', overflow,
defaultValue: TextOverflow.clip),
);
properties.add(
DiagnosticsProperty<TextScaler>('textScaler', textScaler,
defaultValue: TextScaler.noScaling),
);
properties.add(IntProperty('maxLines', maxLines, ifNull: 'unlimited'));
properties.add(
EnumProperty<TextWidthBasis>(
'textWidthBasis',
textWidthBasis,
defaultValue: TextWidthBasis.parent,
),
);
properties.add(StringProperty('text', text.toPlainText()));
properties
.add(DiagnosticsProperty<Locale>('locale', locale, defaultValue: null));
properties.add(DiagnosticsProperty<StrutStyle>('strutStyle', strutStyle,
defaultValue: null));
properties.add(
DiagnosticsProperty<TextHeightBehavior>(
'textHeightBehavior',
textHeightBehavior,
defaultValue: null,
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -495,10 +495,11 @@ abstract class CommonPublishPageState<T extends CommonPublishPage>
void onChanged(String value) { void onChanged(String value) {
bool isEmpty = value.trim().isEmpty; bool isEmpty = value.trim().isEmpty;
if (!isEmpty && !enablePublish.value) { if (isEmpty) {
enablePublish.value = true;
} else if (isEmpty && enablePublish.value) {
enablePublish.value = false; enablePublish.value = false;
mentions?.clear();
} else {
enablePublish.value = true;
} }
widget.onSave?.call((text: value, mentions: mentions)); widget.onSave?.call((text: value, mentions: mentions));
} }

View File

@@ -1,5 +1,6 @@
import 'dart:math'; import 'dart:math';
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/dialog/report.dart'; import 'package:PiliPlus/common/widgets/dialog/report.dart';
import 'package:PiliPlus/common/widgets/pendant_avatar.dart'; import 'package:PiliPlus/common/widgets/pendant_avatar.dart';
import 'package:PiliPlus/http/constants.dart'; import 'package:PiliPlus/http/constants.dart';
@@ -243,10 +244,7 @@ class AuthorPanel extends StatelessWidget {
children: [ children: [
InkWell( InkWell(
onTap: Get.back, onTap: Get.back,
borderRadius: const BorderRadius.only( borderRadius: StyleString.bottomSheetRadius,
topLeft: Radius.circular(18),
topRight: Radius.circular(18),
),
child: Container( child: Container(
height: 35, height: 35,
padding: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.only(bottom: 2),

View File

@@ -1,6 +1,7 @@
// 内容 // 内容
import 'package:PiliPlus/common/widgets/custom_icon.dart'; import 'package:PiliPlus/common/widgets/custom_icon.dart';
import 'package:PiliPlus/common/widgets/image/image_view.dart'; import 'package:PiliPlus/common/widgets/image/image_view.dart';
import 'package:PiliPlus/common/widgets/text/text.dart' as custom_text;
import 'package:PiliPlus/models/dynamics/result.dart'; import 'package:PiliPlus/models/dynamics/result.dart';
import 'package:PiliPlus/pages/dynamics/widgets/rich_node_panel.dart'; import 'package:PiliPlus/pages/dynamics/widgets/rich_node_panel.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -68,13 +69,12 @@ Widget content(
? const TextStyle(fontSize: 15) ? const TextStyle(fontSize: 15)
: const TextStyle(fontSize: 16), : const TextStyle(fontSize: 16),
) )
: Text.rich( : custom_text.Text.rich(
style: floor == 1 style: floor == 1
? const TextStyle(fontSize: 15) ? const TextStyle(fontSize: 15)
: const TextStyle(fontSize: 14), : const TextStyle(fontSize: 14),
richNodes, richNodes,
maxLines: isSave ? null : 6, maxLines: isSave ? null : 6,
overflow: isSave ? null : TextOverflow.ellipsis,
), ),
if (item.modules.moduleDynamic?.major?.opus?.pics?.isNotEmpty == true) if (item.modules.moduleDynamic?.major?.opus?.pics?.isNotEmpty == true)
LayoutBuilder( LayoutBuilder(

View File

@@ -205,11 +205,12 @@ List<SettingsModel> get extraSettings => [
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Get.back(); Get.back();
int length = int.tryParse(replyLengthLimit) ?? 6;
ReplyItemGrpc.replyLengthLimit = ReplyItemGrpc.replyLengthLimit =
int.tryParse(replyLengthLimit) ?? 6; length == 0 ? null : length;
await GStorage.setting.put( await GStorage.setting.put(
SettingBoxKey.replyLengthLimit, SettingBoxKey.replyLengthLimit,
ReplyItemGrpc.replyLengthLimit, length,
); );
setState(); setState();
}, },

View File

@@ -6,6 +6,7 @@ import 'package:PiliPlus/common/widgets/dialog/report.dart';
import 'package:PiliPlus/common/widgets/image/image_view.dart'; import 'package:PiliPlus/common/widgets/image/image_view.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/common/widgets/pendant_avatar.dart'; import 'package:PiliPlus/common/widgets/pendant_avatar.dart';
import 'package:PiliPlus/common/widgets/text/text.dart' as custom_text;
import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart'
show ReplyInfo, ReplyControl, Content; show ReplyInfo, ReplyControl, Content;
import 'package:PiliPlus/http/init.dart'; import 'package:PiliPlus/http/init.dart';
@@ -13,7 +14,6 @@ import 'package:PiliPlus/http/video.dart';
import 'package:PiliPlus/models/common/badge_type.dart'; import 'package:PiliPlus/models/common/badge_type.dart';
import 'package:PiliPlus/models/common/image_type.dart'; import 'package:PiliPlus/models/common/image_type.dart';
import 'package:PiliPlus/pages/dynamics/widgets/vote.dart'; import 'package:PiliPlus/pages/dynamics/widgets/vote.dart';
import 'package:PiliPlus/pages/save_panel/view.dart';
import 'package:PiliPlus/pages/video/controller.dart'; import 'package:PiliPlus/pages/video/controller.dart';
import 'package:PiliPlus/pages/video/reply/widgets/zan_grpc.dart'; import 'package:PiliPlus/pages/video/reply/widgets/zan_grpc.dart';
import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/accounts.dart';
@@ -68,10 +68,10 @@ class ReplyItemGrpc extends StatelessWidget {
final ValueChanged<ReplyInfo>? onCheckReply; final ValueChanged<ReplyInfo>? onCheckReply;
final ValueChanged<ReplyInfo>? onToggleTop; final ValueChanged<ReplyInfo>? onToggleTop;
static final _voteRegExp = RegExp(r"\{vote:\d+?\}"); static final _voteRegExp = RegExp(r"^\{vote:\d+?\}$");
static final _timeRegExp = RegExp(r'^\b(?:\d+[:])?\d+[:]\d+\b$'); static final _timeRegExp = RegExp(r'^\b(?:\d+[:])?\d+[:]\d+\b$');
static bool enableWordRe = Pref.enableWordRe; static bool enableWordRe = Pref.enableWordRe;
static int replyLengthLimit = Pref.replyLengthLimit; static int? replyLengthLimit = Pref.replyLengthLimit;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -181,6 +181,10 @@ class ReplyItemGrpc extends StatelessWidget {
); );
Widget content(BuildContext context, ThemeData theme) { Widget content(BuildContext context, ThemeData theme) {
final padding = EdgeInsets.only(
left: replyLevel == 0 ? 6 : 45,
right: 6,
);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -218,7 +222,6 @@ class ReplyItemGrpc extends StatelessWidget {
Image.asset( Image.asset(
'assets/images/lv/lv${replyItem.member.isSeniorMember == 1 ? '6_s' : replyItem.member.level}.png', 'assets/images/lv/lv${replyItem.member.isSeniorMember == 1 ? '6_s' : replyItem.member.level}.png',
height: 11, height: 11,
semanticLabel: "等级:${replyItem.member.level}",
), ),
if (replyItem.mid == upMid) if (replyItem.mid == upMid)
const PBadge( const PBadge(
@@ -255,35 +258,15 @@ class ReplyItemGrpc extends StatelessWidget {
], ],
), ),
), ),
// title const SizedBox(height: 10),
Padding( Padding(
padding: EdgeInsets.only( padding: padding,
top: 10, child: custom_text.Text.rich(
left: replyLevel == 0 ? 6 : 45, style: TextStyle(
right: 6,
bottom: 4,
),
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
String text = replyItem.content.message;
TextStyle style = TextStyle(
height: 1.75, height: 1.75,
fontSize: theme.textTheme.bodyMedium!.fontSize, fontSize: theme.textTheme.bodyMedium!.fontSize,
); ),
TextPainter? textPainter; maxLines: replyLevel == 1 ? replyLengthLimit : null,
bool? didExceedMaxLines;
if (replyLevel == 1 && replyLengthLimit != 0) {
textPainter = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: replyLengthLimit,
textDirection: Directionality.of(context),
)..layout(maxWidth: constraints.maxWidth);
didExceedMaxLines = textPainter.didExceedMaxLines;
}
return Semantics(
label: text,
child: Text.rich(
style: style,
TextSpan( TextSpan(
children: [ children: [
if (replyItem.replyControl.isUpTop) ...[ if (replyItem.replyControl.isUpTop) ...[
@@ -305,19 +288,39 @@ class ReplyItemGrpc extends StatelessWidget {
theme, theme,
replyItem, replyItem,
null, null,
textPainter,
didExceedMaxLines,
), ),
], ],
), ),
), ),
); ),
}, if (replyItem.content.pictures.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: padding,
child: LayoutBuilder(
builder: (context, constraints) => imageView(
constraints.maxWidth,
replyItem.content.pictures
.map(
(item) => ImageModel(
width: item.imgWidth,
height: item.imgHeight,
url: item.imgSrc,
),
)
.toList(),
onViewImage: onViewImage,
onDismissed: onDismissed,
callback: callback,
), ),
), ),
),
],
// 操作区域 // 操作区域
if (replyLevel != 0) if (replyLevel != 0) ...[
const SizedBox(height: 4),
buttonAction(context, theme, replyItem.replyControl), buttonAction(context, theme, replyItem.replyControl),
],
// 一楼的评论 // 一楼的评论
if (replyLevel == 1 && replyItem.count > Int64.ZERO) ...[ if (replyLevel == 1 && replyItem.count > Int64.ZERO) ...[
Padding( Padding(
@@ -472,10 +475,6 @@ class ReplyItemGrpc extends StatelessWidget {
child: Container( child: Container(
width: double.infinity, width: double.infinity,
padding: padding, padding: padding,
child: Semantics(
label:
'${childReply.member.name} ${childReply.content.message}',
excludeSemantics: true,
child: Text.rich( child: Text.rich(
style: TextStyle( style: TextStyle(
fontSize: theme.textTheme.bodyMedium!.fontSize, fontSize: theme.textTheme.bodyMedium!.fontSize,
@@ -525,14 +524,11 @@ class ReplyItemGrpc extends StatelessWidget {
theme, theme,
childReply, childReply,
replyItem, replyItem,
null,
null,
), ),
], ],
), ),
), ),
), ),
),
); );
}), }),
if (extraRow) if (extraRow)
@@ -580,81 +576,58 @@ class ReplyItemGrpc extends StatelessWidget {
ThemeData theme, ThemeData theme,
ReplyInfo replyItem, ReplyInfo replyItem,
ReplyInfo? fReplyItem, ReplyInfo? fReplyItem,
TextPainter? textPainter,
bool? didExceedMaxLines,
) { ) {
final String routePath = Get.currentRoute;
bool isVideoPage = routePath.startsWith('/video');
// replyItem 当前回复内容 // replyItem 当前回复内容
// replyReply 查看二楼回复(回复详情)回调 // replyReply 查看二楼回复(回复详情)回调
// fReplyItem 父级回复内容,用作二楼回复(回复详情)展示 // fReplyItem 父级回复内容,用作二楼回复(回复详情)展示
final Content content = replyItem.content; final Content content = replyItem.content;
String message = content.message;
final List<InlineSpan> spanChildren = <InlineSpan>[]; final List<InlineSpan> spanChildren = <InlineSpan>[];
if (didExceedMaxLines == true) { if (content.hasRichText()) {
final textSize = textPainter!.size;
final double maxHeight = textPainter.preferredLineHeight * 6;
var position = textPainter.getPositionForOffset(
Offset(textSize.width, maxHeight),
);
message = message.substring(0, position.offset);
}
// 投票
if (content.hasVote()) {
message = message.replaceAllMapped(_voteRegExp, (Match match) {
spanChildren.add( spanChildren.add(
TextSpan( TextSpan(
text: '投票: ${content.vote.title}', text: '[笔记] ',
style: TextStyle( style: TextStyle(
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
recognizer: TapGestureRecognizer() recognizer: TapGestureRecognizer()
..onTap = () => showVoteDialog(context, content.vote.id.toInt()), ..onTap =
() => PageUtils.handleWebview(content.richText.note.clickUrl),
), ),
); );
return '';
});
} }
// 构建正则表达式 // 构建正则表达式
final List<String> specialTokens = [ final List<String> specialTokens = [
...content.emotes.keys, ...content.emotes.keys,
...content.topics.keys.map((e) => '#$e#'), ...content.topics.keys.map((e) => '#$e#'),
...content.atNameToMid.keys.map((e) => '@$e'), ...content.atNameToMid.keys.map((e) => '@$e'),
...content.urls.keys,
]; ];
List<String> jumpUrlKeysList = content.urls.keys.map<String>((String e) { String patternStr = [
return e; ...specialTokens.map(RegExp.escape),
}).toList(); r'(\b(?:\d+[:])?\d+[:]\d+\b)',
specialTokens.sort((a, b) => b.length.compareTo(a.length)); r'(\{vote:\d+?\})',
String patternStr = specialTokens.map(RegExp.escape).join('|'); Constants.urlPattern,
if (patternStr.isNotEmpty) { ].join('|');
patternStr += "|";
}
patternStr += r'(\b(?:\d+[:])?\d+[:]\d+\b)';
if (jumpUrlKeysList.isNotEmpty) {
patternStr += '|${jumpUrlKeysList.map(RegExp.escape).join('|')}';
}
patternStr += '|${Constants.urlPattern}';
final RegExp pattern = RegExp(patternStr); final RegExp pattern = RegExp(patternStr);
List<String> matchedStrs = [];
late List<String> matchedStrs = [];
void addPlainTextSpan(str) { void addPlainTextSpan(str) {
spanChildren.add(TextSpan( spanChildren.add(TextSpan(text: str));
text: str,
));
} }
// 分割文本并处理每个部分 // 分割文本并处理每个部分
message.splitMapJoin( content.message.splitMapJoin(
pattern, pattern,
onMatch: (Match match) { onMatch: (Match match) {
String matchStr = match[0]!; String matchStr = match[0]!;
if (content.emotes.containsKey(matchStr)) { if (content.emotes.containsKey(matchStr)) {
// 处理表情 // 处理表情
final int size = content.emotes[matchStr]!.size.toInt(); final int size = content.emotes[matchStr]!.size.toInt();
spanChildren.add(WidgetSpan( spanChildren.add(
child: ExcludeSemantics( WidgetSpan(
child: NetworkImgLayer( child: NetworkImgLayer(
src: content.emotes[matchStr]?.hasGifUrl() == true src: content.emotes[matchStr]?.hasGifUrl() == true
? content.emotes[matchStr]?.gifUrl ? content.emotes[matchStr]?.gifUrl
@@ -662,9 +635,9 @@ class ReplyItemGrpc extends StatelessWidget {
type: ImageType.emote, type: ImageType.emote,
width: size * 20, width: size * 20,
height: size * 20, height: size * 20,
semanticsLabel: matchStr, ),
)), ),
)); );
} else if (matchStr.startsWith("@") && } else if (matchStr.startsWith("@") &&
content.atNameToMid.containsKey(matchStr.substring(1))) { content.atNameToMid.containsKey(matchStr.substring(1))) {
// 处理@用户 // 处理@用户
@@ -680,6 +653,18 @@ class ReplyItemGrpc extends StatelessWidget {
..onTap = () => Get.toNamed('/member?mid=$userId'), ..onTap = () => Get.toNamed('/member?mid=$userId'),
), ),
); );
} else if (_voteRegExp.hasMatch(matchStr)) {
spanChildren.add(
TextSpan(
text: '投票: ${content.vote.title}',
style: TextStyle(
color: theme.colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap =
() => showVoteDialog(context, content.vote.id.toInt()),
),
);
} else if (_timeRegExp.hasMatch(matchStr)) { } else if (_timeRegExp.hasMatch(matchStr)) {
matchStr = matchStr.replaceAll('', ':'); matchStr = matchStr.replaceAll('', ':');
bool isValid = false; bool isValid = false;
@@ -702,6 +687,7 @@ class ReplyItemGrpc extends StatelessWidget {
} catch (e) { } catch (e) {
if (kDebugMode) debugPrint('failed to validate: $e'); if (kDebugMode) debugPrint('failed to validate: $e');
} }
bool isVideoPage = Get.currentRoute.startsWith('/video');
spanChildren.add( spanChildren.add(
TextSpan( TextSpan(
text: isValid ? ' $matchStr ' : matchStr, text: isValid ? ' $matchStr ' : matchStr,
@@ -905,58 +891,6 @@ class ReplyItemGrpc extends StatelessWidget {
} }
} }
if (didExceedMaxLines == true) {
spanChildren.add(
TextSpan(
text: '\n查看更多',
style: TextStyle(
color: theme.colorScheme.primary,
),
),
);
}
// 图片渲染
if (content.pictures.isNotEmpty) {
spanChildren
..add(const TextSpan(text: '\n'))
..add(
WidgetSpan(
child: LayoutBuilder(
builder: (context, constraints) => imageView(
constraints.maxWidth,
content.pictures
.map(
(item) => ImageModel(
width: item.imgWidth,
height: item.imgHeight,
url: item.imgSrc,
),
)
.toList(),
onViewImage: onViewImage,
onDismissed: onDismissed,
callback: callback,
),
),
),
);
}
// 笔记链接
if (content.hasRichText()) {
spanChildren.add(
TextSpan(
text: ' 笔记',
style: TextStyle(
color: theme.colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap =
() => PageUtils.handleWebview(content.richText.note.clickUrl),
),
);
}
return TextSpan(children: spanChildren); return TextSpan(children: spanChildren);
} }
@@ -966,61 +900,40 @@ class ReplyItemGrpc extends StatelessWidget {
required VoidCallback onDelete, required VoidCallback onDelete,
required bool isSubReply, required bool isSubReply,
}) { }) {
final ownerMid = Int64(Accounts.main.mid);
Future<void> menuActionHandler(String type) async {
late String message = item.content.message; late String message = item.content.message;
switch (type) { final ownerMid = Int64(Accounts.main.mid);
case 'report': final theme = Theme.of(context);
Get.back(); final errorColor = theme.colorScheme.error;
autoWrapReportDialog( final style = theme.textTheme.titleSmall;
context,
ReportOptions.commentReport, return Padding(
(reasonType, reasonDesc, banUid) async { padding: EdgeInsets.only(
final res = await Request().post( bottom: MediaQuery.paddingOf(context).bottom + 20,
'/x/v2/reply/report',
data: {
'add_blacklist': banUid,
'csrf': Accounts.main.csrf,
'gaia_source': 'main_h5',
'oid': item.oid,
'platform': 'android',
'reason': reasonType,
'rpid': item.id,
'scene': 'main',
'type': 1,
if (reasonType == 0) 'content': reasonDesc!
},
options:
Options(contentType: Headers.formUrlEncodedContentType),
);
if (res.data['code'] == 0) {
onDelete();
}
return res.data as Map;
},
);
break;
case 'copyAll':
Get.back();
Utils.copyText(message);
break;
case 'copyFreedom':
Get.back();
showDialog(
context: context,
builder: (context) {
return Dialog(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: SelectableText(message),
), ),
); child: Column(
}, mainAxisSize: MainAxisSize.min,
); children: [
break; InkWell(
case 'delete': onTap: Get.back,
Get.back(); borderRadius: StyleString.bottomSheetRadius,
child: Container(
height: 35,
padding: const EdgeInsets.only(bottom: 2),
child: Center(
child: Container(
width: 32,
height: 3,
decoration: BoxDecoration(
color: theme.colorScheme.outline,
borderRadius: const BorderRadius.all(Radius.circular(3)),
),
),
),
),
),
if (ownerMid == upMid || ownerMid == item.member.mid)
ListTile(
onTap: () async {
bool? isDelete = await showDialog<bool>( bool? isDelete = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
@@ -1078,70 +991,53 @@ class ReplyItemGrpc extends StatelessWidget {
} else { } else {
SmartDialog.showToast('删除失败, ${result["msg"]}'); SmartDialog.showToast('删除失败, ${result["msg"]}');
} }
break; },
case 'checkReply':
Get.back();
onCheckReply?.call(item);
break;
case 'top':
Get.back();
onToggleTop?.call(item);
break;
case 'saveReply':
Get.back();
SavePanel.toSavePanel(upMid: upMid, item: item);
break;
default:
}
}
final theme = Theme.of(context);
final errorColor = theme.colorScheme.error;
final style = theme.textTheme.titleSmall;
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom + 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: Get.back,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(18),
topRight: Radius.circular(18),
),
child: Container(
height: 35,
padding: const EdgeInsets.only(bottom: 2),
child: Center(
child: Container(
width: 32,
height: 3,
decoration: BoxDecoration(
color: theme.colorScheme.outline,
borderRadius: const BorderRadius.all(Radius.circular(3))),
),
),
),
),
if (ownerMid == upMid || ownerMid == item.member.mid)
ListTile(
onTap: () => menuActionHandler('delete'),
minLeadingWidth: 0, minLeadingWidth: 0,
leading: Icon(Icons.delete_outlined, color: errorColor, size: 19), leading: Icon(Icons.delete_outlined, color: errorColor, size: 19),
title: Text('删除', style: style!.copyWith(color: errorColor)), title: Text('删除', style: style!.copyWith(color: errorColor)),
), ),
if (ownerMid != Int64.ZERO) if (ownerMid != Int64.ZERO)
ListTile( ListTile(
onTap: () => menuActionHandler('report'), onTap: () {
Get.back();
autoWrapReportDialog(
context,
ReportOptions.commentReport,
(reasonType, reasonDesc, banUid) async {
final res = await Request().post(
'/x/v2/reply/report',
data: {
'add_blacklist': banUid,
'csrf': Accounts.main.csrf,
'gaia_source': 'main_h5',
'oid': item.oid,
'platform': 'android',
'reason': reasonType,
'rpid': item.id,
'scene': 'main',
'type': 1,
if (reasonType == 0) 'content': reasonDesc!
},
options: Options(
contentType: Headers.formUrlEncodedContentType),
);
if (res.data['code'] == 0) {
onDelete();
}
return res.data as Map;
},
);
},
minLeadingWidth: 0, minLeadingWidth: 0,
leading: Icon(Icons.error_outline, color: errorColor, size: 19), leading: Icon(Icons.error_outline, color: errorColor, size: 19),
title: Text('举报', style: style!.copyWith(color: errorColor)), title: Text('举报', style: style!.copyWith(color: errorColor)),
), ),
if (replyLevel == 1 && !isSubReply && ownerMid == upMid) if (replyLevel == 1 && !isSubReply && ownerMid == upMid)
ListTile( ListTile(
onTap: () => menuActionHandler('top'), onTap: () {
Get.back();
onToggleTop?.call(item);
},
minLeadingWidth: 0, minLeadingWidth: 0,
leading: const Icon(Icons.vertical_align_top, size: 19), leading: const Icon(Icons.vertical_align_top, size: 19),
title: Text( title: Text(
@@ -1150,26 +1046,104 @@ class ReplyItemGrpc extends StatelessWidget {
), ),
), ),
ListTile( ListTile(
onTap: () => menuActionHandler('copyAll'), onTap: () {
Get.back();
Utils.copyText(message);
},
minLeadingWidth: 0, minLeadingWidth: 0,
leading: const Icon(Icons.copy_all_outlined, size: 19), leading: const Icon(Icons.copy_all_outlined, size: 19),
title: Text('复制全部', style: style), title: Text('复制全部', style: style),
), ),
ListTile( ListTile(
onTap: () => menuActionHandler('copyFreedom'), onTap: () {
Get.back();
showDialog(
context: context,
builder: (context) {
return Dialog(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 16),
child: SelectableText(message),
),
);
},
);
},
minLeadingWidth: 0, minLeadingWidth: 0,
leading: const Icon(Icons.copy_outlined, size: 19), leading: const Icon(Icons.copy_outlined, size: 19),
title: Text('自由复制', style: style), title: Text('自由复制', style: style),
), ),
ListTile( ListTile(
onTap: () => menuActionHandler('saveReply'), onTap: () async {
Get.back();
bool? isDelete = await showDialog<bool>(
context: context,
builder: (context) {
final theme = Theme.of(context);
return AlertDialog(
title: const Text('删除评论'),
content: Text.rich(
TextSpan(
children: [
const TextSpan(text: '确定删除这条评论吗?\n\n'),
if (ownerMid != item.member.mid.toInt()) ...[
TextSpan(
text: '@${item.member.name}',
style: TextStyle(
color: theme.colorScheme.primary,
),
),
const TextSpan(text: ':\n'),
],
TextSpan(text: message),
],
),
),
actions: <Widget>[
TextButton(
onPressed: () => Get.back(result: false),
child: Text(
'取消',
style: TextStyle(
color: theme.colorScheme.outline,
),
),
),
TextButton(
onPressed: () => Get.back(result: true),
child: const Text('确定'),
),
],
);
},
);
if (isDelete == true) {
SmartDialog.showLoading(msg: '删除中...');
var result = await VideoHttp.replyDel(
type: item.type.toInt(),
oid: item.oid.toInt(),
rpid: item.id.toInt(),
);
SmartDialog.dismiss();
if (result['status']) {
SmartDialog.showToast('删除成功');
onDelete();
} else {
SmartDialog.showToast('删除失败, ${result["msg"]}');
}
}
},
minLeadingWidth: 0, minLeadingWidth: 0,
leading: const Icon(Icons.save_alt, size: 19), leading: const Icon(Icons.save_alt, size: 19),
title: Text('保存评论', style: style), title: Text('保存评论', style: style),
), ),
if (item.mid == ownerMid) if (item.mid == ownerMid)
ListTile( ListTile(
onTap: () => menuActionHandler('checkReply'), onTap: () {
Get.back();
onCheckReply?.call(item);
},
minLeadingWidth: 0, minLeadingWidth: 0,
leading: const Stack( leading: const Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,

View File

@@ -305,8 +305,13 @@ class Pref {
static bool get horizontalMemberPage => static bool get horizontalMemberPage =>
_setting.get(SettingBoxKey.horizontalMemberPage, defaultValue: false); _setting.get(SettingBoxKey.horizontalMemberPage, defaultValue: false);
static int get replyLengthLimit => static int? get replyLengthLimit {
_setting.get(SettingBoxKey.replyLengthLimit, defaultValue: 6); int length = _setting.get(SettingBoxKey.replyLengthLimit, defaultValue: 6);
if (length <= 0) {
return null;
}
return length;
}
static int get defaultPicQa => static int get defaultPicQa =>
_setting.get(SettingBoxKey.defaultPicQa, defaultValue: 10); _setting.get(SettingBoxKey.defaultPicQa, defaultValue: 10);

View File

@@ -1,3 +1,4 @@
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/main.dart'; import 'package:PiliPlus/main.dart';
import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/extension.dart';
import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/storage_pref.dart';
@@ -91,10 +92,7 @@ class ThemeUtils {
bottomSheetTheme: BottomSheetThemeData( bottomSheetTheme: BottomSheetThemeData(
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only( borderRadius: StyleString.bottomSheetRadius,
topLeft: Radius.circular(18),
topRight: Radius.circular(18),
),
), ),
), ),
// ignore: deprecated_member_use // ignore: deprecated_member_use