diff --git a/mobile/lib/widgets/asset_grid/trash_delete_dialog.dart b/mobile/lib/widgets/asset_grid/trash_delete_dialog.dart index 2e0fae76a3..eaca04b481 100644 --- a/mobile/lib/widgets/asset_grid/trash_delete_dialog.dart +++ b/mobile/lib/widgets/asset_grid/trash_delete_dialog.dart @@ -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, diff --git a/mobile/packages/ui/lib/immich_ui.dart b/mobile/packages/ui/lib/immich_ui.dart index 909ab65bce..c9e510a162 100644 --- a/mobile/packages/ui/lib/immich_ui.dart +++ b/mobile/packages/ui/lib/immich_ui.dart @@ -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'; diff --git a/mobile/packages/ui/lib/src/components/formatted_text.dart b/mobile/packages/ui/lib/src/components/formatted_text.dart new file mode 100644 index 0000000000..95e42d834d --- /dev/null +++ b/mobile/packages/ui/lib/src/components/formatted_text.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: +/// - `` for bold text +/// - `` or any tag ending with `-link` for tappable links +/// +/// Tags must not be nested. Each tag is matched independently left-to-right. +/// +/// By default, `` 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 {count} items?') +/// ``` +/// +/// Link example: +/// ```dart +/// ImmichFormattedText( +/// 'Refer to docs and other', +/// 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 createState() => _ImmichFormattedTextState(); +} + +class _ImmichFormattedTextState extends State { + final _recognizers = []; + + // Matches , , or any *-link tag and its content. + static final _tagPattern = RegExp(r'<(b|link|[\w]+-link)>(.*?)', caseSensitive: false, dotAll: true); + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + void _disposeRecognizers() { + for (final recognizer in _recognizers) { + recognizer.dispose(); + } + _recognizers.clear(); + } + + List _buildSpans() { + _disposeRecognizers(); + + final spans = []; + 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, + ); + } +} diff --git a/mobile/packages/ui/lib/src/components/html_text.dart b/mobile/packages/ui/lib/src/components/html_text.dart deleted file mode 100644 index 72b54b8da5..0000000000 --- a/mobile/packages/ui/lib/src/components/html_text.dart +++ /dev/null @@ -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 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: -/// - `` or `` for bold text -/// - `` or any tag ending with `-link` for tappable links -/// -/// Example: -/// ```dart -/// ImmichHtmlText( -/// 'Refer to docs and other', -/// 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? 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 createState() => _ImmichHtmlTextState(); -} - -class _ImmichHtmlTextState extends State { - final _recognizers = []; - 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)); - } - } - - /// `` tags are preprocessed to `` tags because `` 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)>(.*?)', caseSensitive: false), - (match) => '${match.group(2)}', - ) - .replaceAllMapped( - RegExp(r'<(link)\s*/>', caseSensitive: false), - (match) => '', - ); - } - - @override - void dispose() { - _disposeRecognizers(); - super.dispose(); - } - - void _disposeRecognizers() { - for (final recognizer in _recognizers) { - recognizer.dispose(); - } - _recognizers.clear(); - } - - List _buildSpans() { - _disposeRecognizers(); - - return _document.nodes.expand((node) => _buildNode(node, null, null)).toList(); - } - - Iterable _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, - ); - } -} diff --git a/mobile/packages/ui/pubspec.lock b/mobile/packages/ui/pubspec.lock index c74422dd97..697e1debf5 100644 --- a/mobile/packages/ui/pubspec.lock +++ b/mobile/packages/ui/pubspec.lock @@ -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: diff --git a/mobile/packages/ui/pubspec.yaml b/mobile/packages/ui/pubspec.yaml index d23f34f1a7..a25dfb6ca4 100644 --- a/mobile/packages/ui/pubspec.yaml +++ b/mobile/packages/ui/pubspec.yaml @@ -7,7 +7,6 @@ environment: dependencies: flutter: sdk: flutter - html: ^0.15.6 dev_dependencies: flutter_test: diff --git a/mobile/packages/ui/showcase/lib/pages/components/examples/formatted_text_bold_text.dart b/mobile/packages/ui/showcase/lib/pages/components/examples/formatted_text_bold_text.dart new file mode 100644 index 0000000000..7e36ac7537 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/examples/formatted_text_bold_text.dart @@ -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 bold text.'); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_links.dart b/mobile/packages/ui/showcase/lib/pages/components/examples/formatted_text_links.dart similarity index 53% rename from mobile/packages/ui/showcase/lib/pages/components/examples/html_text_links.dart rename to mobile/packages/ui/showcase/lib/pages/components/examples/formatted_text_links.dart index a764d7173e..3910a5117a 100644 --- a/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_links.dart +++ b/mobile/packages/ui/showcase/lib/pages/components/examples/formatted_text_links.dart @@ -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 documentation or visit GitHub.', - 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, }, - }, + ), ); } } diff --git a/mobile/packages/ui/showcase/lib/pages/components/examples/formatted_text_mixed_tags.dart b/mobile/packages/ui/showcase/lib/pages/components/examples/formatted_text_mixed_tags.dart new file mode 100644 index 0000000000..3490b1c386 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/examples/formatted_text_mixed_tags.dart @@ -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 bold text and links 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!'))), + ), + }, + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_bold_text.dart b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_bold_text.dart deleted file mode 100644 index af4c87f40e..0000000000 --- a/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_bold_text.dart +++ /dev/null @@ -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 bold text and strong text.', - ); - } -} diff --git a/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_nested_tags.dart b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_nested_tags.dart deleted file mode 100644 index 836d949b66..0000000000 --- a/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_nested_tags.dart +++ /dev/null @@ -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 combine bold and links together.', - linkHandlers: { - 'link': () { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Nested link clicked!'))); - }, - }, - ); - } -} diff --git a/mobile/packages/ui/showcase/lib/pages/components/formatted_text_page.dart b/mobile/packages/ui/showcase/lib/pages/components/formatted_text_page.dart new file mode 100644 index 0000000000..b827e0340b --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/formatted_text_page.dart @@ -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', + ), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/html_text_page.dart b/mobile/packages/ui/showcase/lib/pages/components/html_text_page.dart deleted file mode 100644 index 64dbc70597..0000000000 --- a/mobile/packages/ui/showcase/lib/pages/components/html_text_page.dart +++ /dev/null @@ -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', - ), - ], - ), - ); - } -} diff --git a/mobile/packages/ui/showcase/lib/router.dart b/mobile/packages/ui/showcase/lib/router.dart index 014de44fd8..34393da508 100644 --- a/mobile/packages/ui/showcase/lib/router.dart +++ b/mobile/packages/ui/showcase/lib/router.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(), }, ), diff --git a/mobile/packages/ui/showcase/lib/routes.dart b/mobile/packages/ui/showcase/lib/routes.dart index a39fb7bc34..4feeeafdb6 100644 --- a/mobile/packages/ui/showcase/lib/routes.dart +++ b/mobile/packages/ui/showcase/lib/routes.dart @@ -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, ), diff --git a/mobile/packages/ui/showcase/pubspec.lock b/mobile/packages/ui/showcase/pubspec.lock index b0725051d3..c79e6c18c7 100644 --- a/mobile/packages/ui/showcase/pubspec.lock +++ b/mobile/packages/ui/showcase/pubspec.lock @@ -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: diff --git a/mobile/packages/ui/test/html_test.dart b/mobile/packages/ui/test/formatted_text_test.dart similarity index 64% rename from mobile/packages/ui/test/html_test.dart rename to mobile/packages/ui/test/formatted_text_test.dart index 27f68ff66c..54ef343727 100644 --- a/mobile/packages/ui/test/html_test.dart +++ b/mobile/packages/ui/test/formatted_text_test.dart @@ -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 _getContentSpans(WidgetTester tester) { final richText = tester.widget(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 example of HTML text with bold.', - 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()); - - 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 tag', (tester) async { await tester.pumpTestWidget( - const ImmichHtmlText('This is bold text'), + const ImmichFormattedText('This is bold 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 tag', (tester) async { - await tester.pumpTestWidget( - const ImmichHtmlText('This is 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 bold and nested'), - ); - - 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 tag', (tester) async { await tester.pumpTestWidget( - ImmichHtmlText( + ImmichFormattedText( 'This is a custom 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 here', - 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 and other', - 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 here', - 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: click me', - 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 or help', - linkHandlers: { - 'docs-link': () => firstLinkTapped = true, - 'help-link': () => secondLinkTapped = true, - }, + spanBuilder: (tag) => FormattedSpan(onTap: switch (tag) { + 'docs-link' => () => firstLinkTapped = true, + 'help-link' => () => secondLinkTapped = true, + _ => null, + },), ), );