From 951781f41526c6e66f60ecc43c3790927fe637e3 Mon Sep 17 00:00:00 2001 From: Moustapha Kodjo Amadou <107993382+kodjodevf@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:19:39 +0100 Subject: [PATCH] Refactor ReadMoreWidget to use a new ExpandableText implementation --- .../manga/detail/widgets/expandable_text.dart | 555 ++++++++++++++++++ .../manga/detail/widgets/readmore.dart | 85 +-- pubspec.lock | 8 - pubspec.yaml | 1 - 4 files changed, 580 insertions(+), 69 deletions(-) create mode 100644 lib/modules/manga/detail/widgets/expandable_text.dart diff --git a/lib/modules/manga/detail/widgets/expandable_text.dart b/lib/modules/manga/detail/widgets/expandable_text.dart new file mode 100644 index 00000000..5a04f56d --- /dev/null +++ b/lib/modules/manga/detail/widgets/expandable_text.dart @@ -0,0 +1,555 @@ +import 'dart:math'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +typedef StringCallback = void Function(String value); + +class ExpandableText extends StatefulWidget { + const ExpandableText( + this.text, { + super.key, + required this.expandText, + this.collapseText, + this.expanded = false, + this.onExpandedChanged, + this.onLinkTap, + this.linkColor, + this.linkEllipsis = true, + this.linkStyle, + this.prefixText, + this.prefixStyle, + this.onPrefixTap, + this.urlStyle, + this.onUrlTap, + this.hashtagStyle, + this.onHashtagTap, + this.mentionStyle, + this.onMentionTap, + this.expandOnTextTap = false, + this.collapseOnTextTap = false, + this.style, + this.textDirection, + this.textAlign, + this.textScaler, + this.maxLines = 2, + this.animation = false, + this.animationDuration, + this.animationCurve, + this.semanticsLabel, + this.showGradientOverlay = false, + this.gradientOverlayHeight = 30.0, + this.showExpandCollapseIcon = false, + this.expandIcon, + this.collapseIcon, + }) : assert(maxLines > 0); + + final String text; + final String expandText; + final String? collapseText; + final bool expanded; + final ValueChanged? onExpandedChanged; + final VoidCallback? onLinkTap; + final Color? linkColor; + final bool linkEllipsis; + final TextStyle? linkStyle; + final String? prefixText; + final TextStyle? prefixStyle; + final VoidCallback? onPrefixTap; + final TextStyle? urlStyle; + final StringCallback? onUrlTap; + final TextStyle? hashtagStyle; + final StringCallback? onHashtagTap; + final TextStyle? mentionStyle; + final StringCallback? onMentionTap; + final bool expandOnTextTap; + final bool collapseOnTextTap; + final TextStyle? style; + final TextDirection? textDirection; + final TextAlign? textAlign; + final TextScaler? textScaler; + final int maxLines; + final bool animation; + final Duration? animationDuration; + final Curve? animationCurve; + final String? semanticsLabel; + final bool showGradientOverlay; + final double gradientOverlayHeight; + final bool showExpandCollapseIcon; + final IconData? expandIcon; + final IconData? collapseIcon; + + @override + ExpandableTextState createState() => ExpandableTextState(); +} + +class ExpandableTextState extends State + with TickerProviderStateMixin { + bool _expanded = false; + late TapGestureRecognizer _linkTapGestureRecognizer; + late TapGestureRecognizer _prefixTapGestureRecognizer; + + List _textSegments = []; + final List _textSegmentsTapGestureRecognizers = []; + + @override + void initState() { + super.initState(); + + _expanded = widget.expanded; + _linkTapGestureRecognizer = TapGestureRecognizer()..onTap = _linkTapped; + _prefixTapGestureRecognizer = TapGestureRecognizer()..onTap = _prefixTapped; + + _updateText(); + } + + @override + void didUpdateWidget(ExpandableText oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.text != widget.text || + oldWidget.onUrlTap != widget.onUrlTap || + oldWidget.onHashtagTap != widget.onHashtagTap || + oldWidget.onMentionTap != widget.onMentionTap) { + _updateText(); + } + } + + @override + void dispose() { + _linkTapGestureRecognizer.dispose(); + _prefixTapGestureRecognizer.dispose(); + for (var recognizer in _textSegmentsTapGestureRecognizers) { + recognizer.dispose(); + } + super.dispose(); + } + + void _linkTapped() { + if (widget.onLinkTap != null) { + widget.onLinkTap!(); + return; + } + + final toggledExpanded = !_expanded; + + setState(() => _expanded = toggledExpanded); + + widget.onExpandedChanged?.call(toggledExpanded); + } + + void _prefixTapped() { + widget.onPrefixTap?.call(); + } + + @override + Widget build(BuildContext context) { + final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context); + var effectiveTextStyle = widget.style; + if (widget.style == null || widget.style!.inherit) { + effectiveTextStyle = defaultTextStyle.style.merge(widget.style); + } + + final linkText = + (_expanded ? widget.collapseText : widget.expandText) ?? ''; + final linkColor = + widget.linkColor ?? + widget.linkStyle?.color ?? + Theme.of(context).colorScheme.secondary; + final linkTextStyle = effectiveTextStyle! + .merge(widget.linkStyle) + .copyWith(color: linkColor); + + final prefixText = + widget.prefixText != null && widget.prefixText!.isNotEmpty + ? '${widget.prefixText} ' + : ''; + + final link = TextSpan( + children: [ + if (!_expanded) + TextSpan( + text: '\u2026 ', + style: widget.linkEllipsis ? linkTextStyle : effectiveTextStyle, + recognizer: widget.linkEllipsis ? _linkTapGestureRecognizer : null, + ), + if (linkText.isNotEmpty) + TextSpan( + style: effectiveTextStyle, + children: [ + if (_expanded) const TextSpan(text: ' '), + TextSpan( + text: linkText, + style: linkTextStyle, + recognizer: _linkTapGestureRecognizer, + ), + ], + ), + ], + ); + + final prefix = TextSpan( + text: prefixText, + style: effectiveTextStyle.merge(widget.prefixStyle), + recognizer: _prefixTapGestureRecognizer, + ); + + final text = _textSegments.isNotEmpty + ? TextSpan( + children: _buildTextSpans(_textSegments, effectiveTextStyle, null), + ) + : TextSpan(text: widget.text); + + final content = TextSpan( + children: [prefix, text], + style: effectiveTextStyle, + ); + + Widget result = LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + assert(constraints.hasBoundedWidth); + final double maxWidth = constraints.maxWidth; + + final textAlign = + widget.textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start; + final textDirection = + widget.textDirection ?? Directionality.of(context); + final textScaler = + widget.textScaler ?? MediaQuery.textScalerOf(context); + final locale = Localizations.maybeLocaleOf(context); + + TextPainter textPainter = TextPainter( + text: link, + textAlign: textAlign, + textDirection: textDirection, + textScaler: textScaler, + maxLines: widget.maxLines, + locale: locale, + ); + textPainter.layout(minWidth: constraints.minWidth, maxWidth: maxWidth); + final linkSize = textPainter.size; + + textPainter.text = content; + textPainter.layout(minWidth: constraints.minWidth, maxWidth: maxWidth); + final textSize = textPainter.size; + + final bool hasExceededMaxLines = textPainter.didExceedMaxLines; + + TextSpan textSpan; + if (hasExceededMaxLines) { + final position = textPainter.getPositionForOffset( + Offset(textSize.width - linkSize.width, textSize.height), + ); + final endOffset = + (textPainter.getOffsetBefore(position.offset) ?? 0) - + prefixText.length; + + final recognizer = + (_expanded ? widget.collapseOnTextTap : widget.expandOnTextTap) + ? _linkTapGestureRecognizer + : null; + + final text = _textSegments.isNotEmpty + ? TextSpan( + children: _buildTextSpans( + _expanded + ? _textSegments + : parseText( + widget.text.substring(0, max(endOffset, 0)), + ), + effectiveTextStyle!, + recognizer, + ), + ) + : TextSpan( + text: _expanded + ? widget.text + : widget.text.substring(0, max(endOffset, 0)), + recognizer: recognizer, + ); + + textSpan = TextSpan( + style: effectiveTextStyle, + children: [prefix, text, link], + ); + } else { + textSpan = content; + } + + final selectableText = SelectableText.rich( + textSpan, + textDirection: textDirection, + textAlign: textAlign, + textScaler: textScaler, + ); + + Widget textWidget = selectableText; + + if (widget.animation) { + textWidget = AnimatedSize( + duration: + widget.animationDuration ?? const Duration(milliseconds: 200), + curve: widget.animationCurve ?? Curves.fastLinearToSlowEaseIn, + alignment: Alignment.topLeft, + child: textWidget, + ); + } + + // Wrap with Stack to add gradient overlay and icons + if (widget.showGradientOverlay || widget.showExpandCollapseIcon) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Stack( + children: [ + textWidget, + // Gradient overlay when collapsed and text exceeds max lines + if (widget.showGradientOverlay && + !_expanded && + hasExceededMaxLines) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: GestureDetector( + onTap: _linkTapped, + child: Container( + height: widget.gradientOverlayHeight, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Theme.of(context).scaffoldBackgroundColor + .withValues(alpha: 0.2), + Theme.of(context).scaffoldBackgroundColor, + ], + stops: const [0, 0.9], + ), + ), + child: Align( + alignment: Alignment.bottomCenter, + child: Icon( + widget.expandIcon ?? + Icons.keyboard_arrow_down_sharp, + color: Theme.of(context).iconTheme.color, + ), + ), + ), + ), + ), + ], + ), + // Collapse icon when expanded + if (widget.showExpandCollapseIcon && + _expanded && + hasExceededMaxLines) + GestureDetector( + onTap: _linkTapped, + child: SizedBox( + height: 20, + child: Icon( + widget.collapseIcon ?? Icons.keyboard_arrow_up_sharp, + color: Theme.of(context).iconTheme.color, + ), + ), + ), + ], + ); + } + + return textWidget; + }, + ); + + if (widget.semanticsLabel != null) { + result = Semantics( + textDirection: widget.textDirection, + label: widget.semanticsLabel, + child: ExcludeSemantics(child: result), + ); + } + + return result; + } + + void _updateText() { + for (var recognizer in _textSegmentsTapGestureRecognizers) { + recognizer.dispose(); + } + _textSegmentsTapGestureRecognizers.clear(); + + if (widget.onUrlTap == null && + widget.onHashtagTap == null && + widget.onMentionTap == null) { + _textSegments.clear(); + return; + } + + _textSegments = parseText(widget.text); + + for (var element in _textSegments) { + if (element.isUrl && widget.onUrlTap != null) { + final recognizer = TapGestureRecognizer() + ..onTap = () { + widget.onUrlTap!(element.name!); + }; + + _textSegmentsTapGestureRecognizers.add(recognizer); + } else if (element.isHashtag && widget.onHashtagTap != null) { + final recognizer = TapGestureRecognizer() + ..onTap = () { + widget.onHashtagTap!(element.name!); + }; + + _textSegmentsTapGestureRecognizers.add(recognizer); + } else if (element.isMention && widget.onMentionTap != null) { + final recognizer = TapGestureRecognizer() + ..onTap = () { + widget.onMentionTap!(element.name!); + }; + + _textSegmentsTapGestureRecognizers.add(recognizer); + } + } + } + + List _buildTextSpans( + List segments, + TextStyle textStyle, + TapGestureRecognizer? textTapRecognizer, + ) { + final spans = []; + + var index = 0; + for (var segment in segments) { + TextStyle? style; + TapGestureRecognizer? recognizer; + + if (segment.isUrl && widget.onUrlTap != null) { + style = textStyle.merge(widget.urlStyle); + recognizer = _textSegmentsTapGestureRecognizers[index++]; + } else if (segment.isMention && widget.onMentionTap != null) { + style = textStyle.merge(widget.mentionStyle); + recognizer = _textSegmentsTapGestureRecognizers[index++]; + } else if (segment.isHashtag && widget.onHashtagTap != null) { + style = textStyle.merge(widget.hashtagStyle); + recognizer = _textSegmentsTapGestureRecognizers[index++]; + } + + final span = TextSpan( + text: segment.text, + style: style, + recognizer: recognizer ?? textTapRecognizer, + ); + + spans.add(span); + } + + return spans; + } +} + +class TextSegment { + String text; + + final String? name; + final bool isHashtag; + final bool isMention; + final bool isUrl; + + bool get isText => !isHashtag && !isMention && !isUrl; + + TextSegment( + this.text, [ + this.name, + this.isHashtag = false, + this.isMention = false, + this.isUrl = false, + ]); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TextSegment && + runtimeType == other.runtimeType && + text == other.text && + name == other.name && + isHashtag == other.isHashtag && + isMention == other.isMention && + isUrl == other.isUrl; + + @override + int get hashCode => + text.hashCode ^ + name.hashCode ^ + isHashtag.hashCode ^ + isMention.hashCode ^ + isUrl.hashCode; +} + +/// Split the string into multiple instances of [TextSegment] for mentions, hashtags, URLs and regular text. +/// +/// Mentions are all words that start with @, e.g. @mention. +/// Hashtags are all words that start with #, e.g. #hashtag. +List parseText(String? text) { + final segments = []; + + if (text == null || text.isEmpty) { + return segments; + } + + // parse urls and words starting with @ (mention) or # (hashtag) + RegExp exp = RegExp( + r'(?(#|@)([\p{Alphabetic}\p{Mark}\p{Decimal_Number}\p{Connector_Punctuation}\p{Join_Control}]+)|(?(?:(?:https?|ftp):\/\/)?[-a-z0-9@:%._\+~#=]{1,256}\.[a-z0-9]{1,6}(\/[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)?))', + unicode: true, + ); + final matches = exp.allMatches(text); + + var start = 0; + for (var match in matches) { + // text before the keyword + if (match.start > start) { + if (segments.isNotEmpty && segments.last.isText) { + segments.last.text += text.substring(start, match.start); + } else { + segments.add(TextSegment(text.substring(start, match.start))); + } + start = match.start; + } + + final url = match.namedGroup('url'); + final keyword = match.namedGroup('keyword'); + + if (url != null) { + segments.add(TextSegment(url, url, false, false, true)); + } else if (keyword != null) { + final isWord = + match.start == 0 || + [' ', '\n'].contains(text.substring(match.start - 1, start)); + if (!isWord) { + continue; + } + + final isHashtag = keyword.startsWith('#'); + final isMention = keyword.startsWith('@'); + + segments.add( + TextSegment(keyword, keyword.substring(1), isHashtag, isMention), + ); + } + + start = match.end; + } + + // text after the last keyword or the whole text if it does not contain any keywords + if (start < text.length) { + if (segments.isNotEmpty && segments.last.isText) { + segments.last.text += text.substring(start); + } else { + segments.add(TextSegment(text.substring(start))); + } + } + + return segments; +} diff --git a/lib/modules/manga/detail/widgets/readmore.dart b/lib/modules/manga/detail/widgets/readmore.dart index 786b9672..4dd12172 100644 --- a/lib/modules/manga/detail/widgets/readmore.dart +++ b/lib/modules/manga/detail/widgets/readmore.dart @@ -1,14 +1,13 @@ -import 'package:expandable_text/expandable_text.dart'; import 'package:flutter/material.dart'; +import 'package:mangayomi/modules/manga/detail/widgets/expandable_text.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; -import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; class ReadMoreWidget extends StatefulWidget { const ReadMoreWidget({ super.key, required this.text, required this.onChanged, - this.initExpanded = true, + this.initExpanded = false, }); final Function(bool) onChanged; final String text; @@ -29,63 +28,29 @@ class ReadMoreWidgetState extends State mainAxisAlignment: MainAxisAlignment.center, children: [Text(l10n.no_description)], ) - : Column( - children: [ - Stack( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: ExpandableText( - animationDuration: const Duration(milliseconds: 500), - onExpandedChanged: (value) { - setState(() => expanded = value); - widget.onChanged(value); - }, - expandOnTextTap: true, - widget.text.trim(), - expandText: '', - maxLines: 3, - expanded: expanded, - linkColor: Theme.of(context).scaffoldBackgroundColor, - animation: true, - collapseOnTextTap: true, - prefixText: '', - ), - ), - if (!expanded) - Positioned( - bottom: 0, - right: 0, - left: 0, - child: Container( - width: context.width(1), - height: 30, - decoration: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Theme.of( - context, - ).scaffoldBackgroundColor.withValues(alpha: 0.2), - Theme.of(context).scaffoldBackgroundColor, - ], - stops: const [0, .9], - ), - ), - child: const Icon(Icons.keyboard_arrow_down_sharp), - ), - ), - ], - ), - if (expanded) - SizedBox( - width: context.width(1), - height: 20, - child: const Icon(Icons.keyboard_arrow_up_sharp), - ), - ], + : Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: ExpandableText( + widget.text.trim(), + expandText: '', + maxLines: 3, + expanded: expanded, + linkColor: Colors.transparent, + animation: true, + animationDuration: const Duration(milliseconds: 500), + expandOnTextTap: true, + collapseOnTextTap: true, + prefixText: '', + showGradientOverlay: true, + gradientOverlayHeight: 30, + showExpandCollapseIcon: true, + expandIcon: Icons.keyboard_arrow_down_sharp, + collapseIcon: Icons.keyboard_arrow_up_sharp, + onExpandedChanged: (value) { + setState(() => expanded = value); + widget.onChanged(value); + }, + ), ); } } diff --git a/pubspec.lock b/pubspec.lock index 07dff0c0..ca7edf94 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -451,14 +451,6 @@ packages: url: "https://github.com/kodjodevf/epubx.dart.git" source: git version: "4.0.3" - expandable_text: - dependency: "direct main" - description: - name: expandable_text - sha256: "7d03ea48af6987b20ece232678b744862aa3250d4a71e2aaf1e4af90015d76b1" - url: "https://pub.dev" - source: hosted - version: "2.3.0" expressions: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1ad6f4d2..0a48373d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,6 @@ dependencies: riverpod_annotation: ^3.0.3 html: ^0.15.5 font_awesome_flutter: ^10.12.0 - expandable_text: ^2.3.0 flex_color_scheme: ^8.3.1 extended_image: ^10.0.0 photo_view: ^0.15.0