mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
refactor: ImmichHtmlText to ImmichFormattedText (#26466)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
@@ -13,7 +13,7 @@ class TrashDeleteDialog extends StatelessWidget {
|
||||
return AlertDialog(
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
|
||||
title: Text(context.t.permanently_delete),
|
||||
content: ImmichHtmlText(context.t.permanently_delete_assets_prompt(count: count)),
|
||||
content: ImmichFormattedText(context.t.permanently_delete_assets_prompt(count: count)),
|
||||
actions: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export 'src/components/close_button.dart';
|
||||
export 'src/components/form.dart';
|
||||
export 'src/components/html_text.dart';
|
||||
export 'src/components/formatted_text.dart';
|
||||
export 'src/components/icon_button.dart';
|
||||
export 'src/components/password_input.dart';
|
||||
export 'src/components/text_button.dart';
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FormattedSpan {
|
||||
final TextStyle? style;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const FormattedSpan({this.style, this.onTap});
|
||||
}
|
||||
|
||||
/// A widget that renders text with optional HTML-style formatting.
|
||||
///
|
||||
/// Supports the following tags:
|
||||
/// - `<b>` for bold text
|
||||
/// - `<link>` or any tag ending with `-link` for tappable links
|
||||
///
|
||||
/// Tags must not be nested. Each tag is matched independently left-to-right.
|
||||
///
|
||||
/// By default, `<b>` renders as [FontWeight.bold] and link tags render with an
|
||||
/// underline and no tap handler. Provide [spanBuilder] to attach tap callbacks
|
||||
/// or override styles per tag.
|
||||
///
|
||||
/// Bold-only example (no [spanBuilder] needed):
|
||||
/// ```dart
|
||||
/// ImmichFormattedText('Delete <b>{count}</b> items?')
|
||||
/// ```
|
||||
///
|
||||
/// Link example:
|
||||
/// ```dart
|
||||
/// ImmichFormattedText(
|
||||
/// 'Refer to <docs-link>docs</docs-link> and <other-link>other</other-link>',
|
||||
/// spanBuilder: (tag) => FormattedSpan(
|
||||
/// onTap: switch (tag) {
|
||||
/// 'docs-link' => () => launchUrl(docsUrl),
|
||||
/// 'other-link' => () => launchUrl(otherUrl),
|
||||
/// _ => null,
|
||||
/// },
|
||||
/// ),
|
||||
/// )
|
||||
/// ```
|
||||
class ImmichFormattedText extends StatefulWidget {
|
||||
final String text;
|
||||
final TextStyle? style;
|
||||
final TextAlign? textAlign;
|
||||
final TextOverflow? overflow;
|
||||
final int? maxLines;
|
||||
final bool? softWrap;
|
||||
final FormattedSpan Function(String tag)? spanBuilder;
|
||||
|
||||
const ImmichFormattedText(
|
||||
this.text, {
|
||||
this.spanBuilder,
|
||||
super.key,
|
||||
this.style,
|
||||
this.textAlign,
|
||||
this.overflow,
|
||||
this.maxLines,
|
||||
this.softWrap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImmichFormattedText> createState() => _ImmichFormattedTextState();
|
||||
}
|
||||
|
||||
class _ImmichFormattedTextState extends State<ImmichFormattedText> {
|
||||
final _recognizers = <GestureRecognizer>[];
|
||||
|
||||
// Matches <b>, <link>, or any *-link tag and its content.
|
||||
static final _tagPattern = RegExp(r'<(b|link|[\w]+-link)>(.*?)</\1>', caseSensitive: false, dotAll: true);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeRecognizers();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _disposeRecognizers() {
|
||||
for (final recognizer in _recognizers) {
|
||||
recognizer.dispose();
|
||||
}
|
||||
_recognizers.clear();
|
||||
}
|
||||
|
||||
List<InlineSpan> _buildSpans() {
|
||||
_disposeRecognizers();
|
||||
|
||||
final spans = <InlineSpan>[];
|
||||
int cursor = 0;
|
||||
|
||||
for (final match in _tagPattern.allMatches(widget.text)) {
|
||||
if (match.start > cursor) {
|
||||
spans.add(TextSpan(text: widget.text.substring(cursor, match.start)));
|
||||
}
|
||||
|
||||
final tag = match.group(1)!.toLowerCase();
|
||||
final content = match.group(2)!;
|
||||
final formattedSpan = (widget.spanBuilder ?? _defaultSpanBuilder)(tag);
|
||||
final style = formattedSpan.style ?? _defaultTextStyle(tag);
|
||||
|
||||
GestureRecognizer? recognizer;
|
||||
if (formattedSpan.onTap != null) {
|
||||
recognizer = TapGestureRecognizer()..onTap = formattedSpan.onTap;
|
||||
_recognizers.add(recognizer);
|
||||
}
|
||||
spans.add(TextSpan(text: content, style: style, recognizer: recognizer));
|
||||
|
||||
cursor = match.end;
|
||||
}
|
||||
|
||||
if (cursor < widget.text.length) {
|
||||
spans.add(TextSpan(text: widget.text.substring(cursor)));
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
|
||||
FormattedSpan _defaultSpanBuilder(String tag) => switch (tag) {
|
||||
'b' => const FormattedSpan(style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
'link' => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
|
||||
_ when tag.endsWith('-link') => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
|
||||
_ => const FormattedSpan(),
|
||||
};
|
||||
|
||||
TextStyle? _defaultTextStyle(String tag) => switch (tag) {
|
||||
'b' => const TextStyle(fontWeight: FontWeight.bold),
|
||||
'link' => const TextStyle(decoration: TextDecoration.underline),
|
||||
_ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text.rich(
|
||||
TextSpan(style: widget.style, children: _buildSpans()),
|
||||
textAlign: widget.textAlign,
|
||||
overflow: widget.overflow,
|
||||
maxLines: widget.maxLines,
|
||||
softWrap: widget.softWrap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:html/dom.dart' as dom;
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
|
||||
enum _HtmlTagType {
|
||||
bold,
|
||||
link,
|
||||
unsupported,
|
||||
}
|
||||
|
||||
class _HtmlTag {
|
||||
final _HtmlTagType type;
|
||||
final String tagName;
|
||||
|
||||
const _HtmlTag._({required this.type, required this.tagName});
|
||||
|
||||
static const unsupported = _HtmlTag._(type: _HtmlTagType.unsupported, tagName: 'unsupported');
|
||||
|
||||
static _HtmlTag? fromString(dom.Node node) {
|
||||
final tagName = (node is dom.Element) ? node.localName : null;
|
||||
if (tagName == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final tag = tagName.toLowerCase();
|
||||
return switch (tag) {
|
||||
'b' || 'strong' => _HtmlTag._(type: _HtmlTagType.bold, tagName: tag),
|
||||
// Convert <a> back to 'link' for handler lookup
|
||||
'a' => const _HtmlTag._(type: _HtmlTagType.link, tagName: 'link'),
|
||||
_ when tag.endsWith('-link') => _HtmlTag._(type: _HtmlTagType.link, tagName: tag),
|
||||
_ => _HtmlTag.unsupported,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget that renders text with optional HTML-style formatting.
|
||||
///
|
||||
/// Supports the following tags:
|
||||
/// - `<b>` or `<strong>` for bold text
|
||||
/// - `<link>` or any tag ending with `-link` for tappable links
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// ImmichHtmlText(
|
||||
/// 'Refer to <link>docs</link> and <other-link>other</other-link>',
|
||||
/// linkHandlers: {
|
||||
/// 'link': () => launchUrl(docsUrl),
|
||||
/// 'other-link': () => launchUrl(otherUrl),
|
||||
/// },
|
||||
/// )
|
||||
/// ```
|
||||
class ImmichHtmlText extends StatefulWidget {
|
||||
final String text;
|
||||
final TextStyle? style;
|
||||
final TextAlign? textAlign;
|
||||
final TextOverflow? overflow;
|
||||
final int? maxLines;
|
||||
final bool? softWrap;
|
||||
final Map<String, VoidCallback>? linkHandlers;
|
||||
final TextStyle? linkStyle;
|
||||
|
||||
const ImmichHtmlText(
|
||||
this.text, {
|
||||
super.key,
|
||||
this.style,
|
||||
this.textAlign,
|
||||
this.overflow,
|
||||
this.maxLines,
|
||||
this.softWrap,
|
||||
this.linkHandlers,
|
||||
this.linkStyle,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImmichHtmlText> createState() => _ImmichHtmlTextState();
|
||||
}
|
||||
|
||||
class _ImmichHtmlTextState extends State<ImmichHtmlText> {
|
||||
final _recognizers = <GestureRecognizer>[];
|
||||
dom.DocumentFragment _document = dom.DocumentFragment();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_document = html_parser.parseFragment(_preprocessHtml(widget.text));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ImmichHtmlText oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.text != widget.text) {
|
||||
_document = html_parser.parseFragment(_preprocessHtml(widget.text));
|
||||
}
|
||||
}
|
||||
|
||||
/// `<link>` tags are preprocessed to `<a>` tags because `<link>` is a
|
||||
/// void element in HTML5 and cannot have children. The linkHandlers still use
|
||||
/// 'link' as the key.
|
||||
String _preprocessHtml(String html) {
|
||||
return html
|
||||
.replaceAllMapped(
|
||||
RegExp(r'<(link)>(.*?)</\1>', caseSensitive: false),
|
||||
(match) => '<a>${match.group(2)}</a>',
|
||||
)
|
||||
.replaceAllMapped(
|
||||
RegExp(r'<(link)\s*/>', caseSensitive: false),
|
||||
(match) => '<a></a>',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeRecognizers();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _disposeRecognizers() {
|
||||
for (final recognizer in _recognizers) {
|
||||
recognizer.dispose();
|
||||
}
|
||||
_recognizers.clear();
|
||||
}
|
||||
|
||||
List<InlineSpan> _buildSpans() {
|
||||
_disposeRecognizers();
|
||||
|
||||
return _document.nodes.expand((node) => _buildNode(node, null, null)).toList();
|
||||
}
|
||||
|
||||
Iterable<InlineSpan> _buildNode(
|
||||
dom.Node node,
|
||||
TextStyle? style,
|
||||
_HtmlTag? parentTag,
|
||||
) sync* {
|
||||
if (node is dom.Text) {
|
||||
if (node.text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
GestureRecognizer? recognizer;
|
||||
if (parentTag?.type == _HtmlTagType.link) {
|
||||
final handler = widget.linkHandlers?[parentTag?.tagName];
|
||||
if (handler != null) {
|
||||
recognizer = TapGestureRecognizer()..onTap = handler;
|
||||
_recognizers.add(recognizer);
|
||||
}
|
||||
}
|
||||
|
||||
yield TextSpan(text: node.text, style: style, recognizer: recognizer);
|
||||
} else if (node is dom.Element) {
|
||||
final htmlTag = _HtmlTag.fromString(node);
|
||||
final tagStyle = _styleForTag(htmlTag);
|
||||
final mergedStyle = style?.merge(tagStyle) ?? tagStyle;
|
||||
final newParentTag = htmlTag?.type == _HtmlTagType.link ? htmlTag : parentTag;
|
||||
|
||||
for (final child in node.nodes) {
|
||||
yield* _buildNode(child, mergedStyle, newParentTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextStyle? _styleForTag(_HtmlTag? tag) {
|
||||
if (tag == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (tag.type) {
|
||||
_HtmlTagType.bold => const TextStyle(fontWeight: FontWeight.bold),
|
||||
_HtmlTagType.link => widget.linkStyle ??
|
||||
TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
_HtmlTagType.unsupported => null,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text.rich(
|
||||
TextSpan(style: widget.style, children: _buildSpans()),
|
||||
textAlign: widget.textAlign,
|
||||
overflow: widget.overflow,
|
||||
maxLines: widget.maxLines,
|
||||
softWrap: widget.softWrap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,14 +41,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -67,14 +59,6 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
html:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -7,7 +7,6 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
html: ^0.15.6
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class FormattedTextBoldText extends StatelessWidget {
|
||||
const FormattedTextBoldText({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichFormattedText('This is <b>bold text</b>.');
|
||||
}
|
||||
}
|
||||
+11
-12
@@ -1,25 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class HtmlTextLinks extends StatelessWidget {
|
||||
const HtmlTextLinks({super.key});
|
||||
class FormattedTextLinks extends StatelessWidget {
|
||||
const FormattedTextLinks({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichHtmlText(
|
||||
return ImmichFormattedText(
|
||||
'Read the <docs-link>documentation</docs-link> or visit <github-link>GitHub</github-link>.',
|
||||
linkHandlers: {
|
||||
'docs-link': () {
|
||||
ScaffoldMessenger.of(
|
||||
spanBuilder: (tag) => FormattedSpan(
|
||||
onTap: switch (tag) {
|
||||
'docs-link' => () => ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Docs link clicked!')));
|
||||
},
|
||||
'github-link': () {
|
||||
ScaffoldMessenger.of(
|
||||
).showSnackBar(const SnackBar(content: Text('Docs link clicked!'))),
|
||||
'github-link' => () => ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('GitHub link clicked!')));
|
||||
).showSnackBar(const SnackBar(content: Text('GitHub link clicked!'))),
|
||||
_ => null,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class FormattedTextMixedContent extends StatelessWidget {
|
||||
const FormattedTextMixedContent({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichFormattedText(
|
||||
'You can use <b>bold text</b> and <link>links</link> together.',
|
||||
spanBuilder: (tag) => switch (tag) {
|
||||
'b' => const FormattedSpan(
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
_ => FormattedSpan(
|
||||
onTap: () => ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Link clicked!'))),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class HtmlTextBoldText extends StatelessWidget {
|
||||
const HtmlTextBoldText({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichHtmlText(
|
||||
'This is <b>bold text</b> and <strong>strong text</strong>.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class HtmlTextNestedTags extends StatelessWidget {
|
||||
const HtmlTextNestedTags({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImmichHtmlText(
|
||||
'You can <b>combine <link>bold and links</link></b> together.',
|
||||
linkHandlers: {
|
||||
'link': () {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Nested link clicked!')));
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:showcase/pages/components/examples/formatted_text_bold_text.dart';
|
||||
import 'package:showcase/pages/components/examples/formatted_text_links.dart';
|
||||
import 'package:showcase/pages/components/examples/formatted_text_mixed_tags.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class FormattedTextPage extends StatelessWidget {
|
||||
const FormattedTextPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.formattedText.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichFormattedText',
|
||||
subtitle: 'Render text with HTML formatting (bold, links).',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Bold Text',
|
||||
preview: const FormattedTextBoldText(),
|
||||
code: 'formatted_text_bold_text.dart',
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Links',
|
||||
preview: const FormattedTextLinks(),
|
||||
code: 'formatted_text_links.dart',
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Mixed Content',
|
||||
preview: const FormattedTextMixedContent(),
|
||||
code: 'formatted_text_mixed_tags.dart',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:showcase/pages/components/examples/html_text_bold_text.dart';
|
||||
import 'package:showcase/pages/components/examples/html_text_links.dart';
|
||||
import 'package:showcase/pages/components/examples/html_text_nested_tags.dart';
|
||||
import 'package:showcase/routes.dart';
|
||||
import 'package:showcase/widgets/component_examples.dart';
|
||||
import 'package:showcase/widgets/example_card.dart';
|
||||
import 'package:showcase/widgets/page_title.dart';
|
||||
|
||||
class HtmlTextPage extends StatelessWidget {
|
||||
const HtmlTextPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageTitle(
|
||||
title: AppRoute.htmlText.name,
|
||||
child: ComponentExamples(
|
||||
title: 'ImmichHtmlText',
|
||||
subtitle: 'Render text with HTML formatting (bold, links).',
|
||||
examples: [
|
||||
ExampleCard(
|
||||
title: 'Bold Text',
|
||||
preview: const HtmlTextBoldText(),
|
||||
code: 'html_text_bold_text.dart',
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Links',
|
||||
preview: const HtmlTextLinks(),
|
||||
code: 'html_text_links.dart',
|
||||
),
|
||||
ExampleCard(
|
||||
title: 'Nested Tags',
|
||||
preview: const HtmlTextNestedTags(),
|
||||
code: 'html_text_nested_tags.dart',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:showcase/pages/components/close_button_page.dart';
|
||||
import 'package:showcase/pages/components/form_page.dart';
|
||||
import 'package:showcase/pages/components/html_text_page.dart';
|
||||
import 'package:showcase/pages/components/formatted_text_page.dart';
|
||||
import 'package:showcase/pages/components/icon_button_page.dart';
|
||||
import 'package:showcase/pages/components/password_input_page.dart';
|
||||
import 'package:showcase/pages/components/text_button_page.dart';
|
||||
@@ -34,7 +34,7 @@ class AppRouter {
|
||||
AppRoute.textInput => const TextInputPage(),
|
||||
AppRoute.passwordInput => const PasswordInputPage(),
|
||||
AppRoute.form => const FormPage(),
|
||||
AppRoute.htmlText => const HtmlTextPage(),
|
||||
AppRoute.formattedText => const FormattedTextPage(),
|
||||
AppRoute.constants => const ConstantsPage(),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -60,10 +60,10 @@ enum AppRoute {
|
||||
category: AppRouteCategory.forms,
|
||||
icon: Icons.description_outlined,
|
||||
),
|
||||
htmlText(
|
||||
name: 'Html Text',
|
||||
formattedText(
|
||||
name: 'Formatted Text',
|
||||
description: 'Render text with HTML formatting',
|
||||
path: '/html-text',
|
||||
path: '/formatted-text',
|
||||
category: AppRouteCategory.forms,
|
||||
icon: Icons.code_rounded,
|
||||
),
|
||||
|
||||
@@ -49,14 +49,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
device_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -136,14 +128,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "17.0.1"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
immich_ui:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -227,10 +211,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.16.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -328,10 +312,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.6"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+29
-84
@@ -1,21 +1,16 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_ui/src/components/html_text.dart';
|
||||
import 'package:immich_ui/src/components/formatted_text.dart';
|
||||
|
||||
import 'test_utils.dart';
|
||||
|
||||
/// Text.rich creates a nested structure: root -> wrapper -> actual children
|
||||
/// Text.rich creates a nested structure: root (DefaultTextStyle) -> wrapper (ImmichFormattedText) -> actual children
|
||||
List<InlineSpan> _getContentSpans(WidgetTester tester) {
|
||||
final richText = tester.widget<RichText>(find.byType(RichText));
|
||||
final root = richText.text as TextSpan;
|
||||
|
||||
if (root.children?.isNotEmpty ?? false) {
|
||||
final wrapper = root.children!.first;
|
||||
if (wrapper is TextSpan && wrapper.children != null) {
|
||||
return wrapper.children!;
|
||||
}
|
||||
}
|
||||
final wrapper = root.children?.firstOrNull;
|
||||
if (wrapper is TextSpan) return wrapper.children ?? [];
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -38,42 +33,18 @@ void _triggerTap(TextSpan span) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('ImmichHtmlText', () {
|
||||
group('ImmichFormattedText', () {
|
||||
testWidgets('renders plain text without HTML tags', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText('This is plain text'),
|
||||
const ImmichFormattedText('This is plain text'),
|
||||
);
|
||||
|
||||
expect(find.text('This is plain text'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('handles mixed content with bold and links', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
'This is an <b>example</b> of <b><link>HTML text</link></b> with <b>bold</b>.',
|
||||
linkHandlers: {'link': () {}},
|
||||
),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
|
||||
final exampleSpan = _findSpan(spans, 'example');
|
||||
expect(exampleSpan.style?.fontWeight, FontWeight.bold);
|
||||
|
||||
final boldSpan = _findSpan(spans, 'bold');
|
||||
expect(boldSpan.style?.fontWeight, FontWeight.bold);
|
||||
|
||||
final linkSpan = _findSpan(spans, 'HTML text');
|
||||
expect(linkSpan.style?.decoration, TextDecoration.underline);
|
||||
expect(linkSpan.style?.fontWeight, FontWeight.bold);
|
||||
expect(linkSpan.recognizer, isA<TapGestureRecognizer>());
|
||||
|
||||
expect(_concatenateText(spans), 'This is an example of HTML text with bold.');
|
||||
});
|
||||
|
||||
testWidgets('applies text style properties', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText(
|
||||
const ImmichFormattedText(
|
||||
'Test text',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
@@ -97,7 +68,7 @@ void main() {
|
||||
|
||||
testWidgets('handles text with special characters', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText('Text with & < > " \' characters'),
|
||||
const ImmichFormattedText('Text with & < > " \' characters'),
|
||||
);
|
||||
|
||||
expect(find.byType(RichText), findsOneWidget);
|
||||
@@ -109,7 +80,7 @@ void main() {
|
||||
group('bold', () {
|
||||
testWidgets('renders bold text with <b> tag', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText('This is <b>bold</b> text'),
|
||||
const ImmichFormattedText('This is <b>bold</b> text'),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
@@ -118,41 +89,14 @@ void main() {
|
||||
expect(boldSpan.style?.fontWeight, FontWeight.bold);
|
||||
expect(_concatenateText(spans), 'This is bold text');
|
||||
});
|
||||
|
||||
testWidgets('renders bold text with <strong> tag', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText('This is <strong>strong</strong> text'),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
final strongSpan = _findSpan(spans, 'strong');
|
||||
|
||||
expect(strongSpan.style?.fontWeight, FontWeight.bold);
|
||||
});
|
||||
|
||||
testWidgets('handles nested bold tags', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText('Text with <b>bold and <strong>nested</strong></b>'),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
|
||||
final nestedSpan = _findSpan(spans, 'nested');
|
||||
expect(nestedSpan.style?.fontWeight, FontWeight.bold);
|
||||
|
||||
final boldSpan = _findSpan(spans, 'bold and ');
|
||||
expect(boldSpan.style?.fontWeight, FontWeight.bold);
|
||||
|
||||
expect(_concatenateText(spans), 'Text with bold and nested');
|
||||
});
|
||||
});
|
||||
|
||||
group('link', () {
|
||||
testWidgets('renders link text with <link> tag', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
ImmichFormattedText(
|
||||
'This is a <link>custom link</link> text',
|
||||
linkHandlers: {'link': () {}},
|
||||
spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) { 'link' => () {}, _ => null }),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -167,9 +111,9 @@ void main() {
|
||||
var linkTapped = false;
|
||||
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
ImmichFormattedText(
|
||||
'Tap <link>here</link>',
|
||||
linkHandlers: {'link': () => linkTapped = true},
|
||||
spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) { 'link' => () => linkTapped = true, _ => null }),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -183,12 +127,13 @@ void main() {
|
||||
|
||||
testWidgets('handles custom prefixed link tags', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
ImmichFormattedText(
|
||||
'Refer to <docs-link>docs</docs-link> and <other-link>other</other-link>',
|
||||
linkHandlers: {
|
||||
'docs-link': () {},
|
||||
'other-link': () {},
|
||||
},
|
||||
spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) {
|
||||
'docs-link' => () {},
|
||||
'other-link' => () {},
|
||||
_ => null,
|
||||
},),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -207,10 +152,9 @@ void main() {
|
||||
);
|
||||
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
ImmichFormattedText(
|
||||
'Click <link>here</link>',
|
||||
linkStyle: customLinkStyle,
|
||||
linkHandlers: {'link': () {}},
|
||||
spanBuilder: (tag) => FormattedSpan(style: customLinkStyle, onTap: () {}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -223,9 +167,9 @@ void main() {
|
||||
|
||||
testWidgets('link without handler renders but is not tappable', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
ImmichFormattedText(
|
||||
'Link without handler: <link>click me</link>',
|
||||
linkHandlers: {'other-link': () {}},
|
||||
spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) { 'other-link' => () {}, _ => null }),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -241,12 +185,13 @@ void main() {
|
||||
var secondLinkTapped = false;
|
||||
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
ImmichFormattedText(
|
||||
'Go to <docs-link>docs</docs-link> or <help-link>help</help-link>',
|
||||
linkHandlers: {
|
||||
'docs-link': () => firstLinkTapped = true,
|
||||
'help-link': () => secondLinkTapped = true,
|
||||
},
|
||||
spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) {
|
||||
'docs-link' => () => firstLinkTapped = true,
|
||||
'help-link' => () => secondLinkTapped = true,
|
||||
_ => null,
|
||||
},),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user