From 194671f23e32129cb236b31ac810755765666cf8 Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Sat, 31 Jan 2026 07:15:59 +0530 Subject: [PATCH] feat: html text --- .../pages/dev/ui_showcase.page.dart | 12 + mobile/packages/ui/.gitignore | 12 + mobile/packages/ui/lib/immich_ui.dart | 1 + .../ui/lib/src/components/html_text.dart | 189 +++++++++++++ mobile/packages/ui/pubspec.lock | 154 +++++++++- mobile/packages/ui/pubspec.yaml | 5 + mobile/packages/ui/test/html_test.dart | 266 ++++++++++++++++++ mobile/packages/ui/test/test_utils.dart | 9 + 8 files changed, 646 insertions(+), 2 deletions(-) create mode 100644 mobile/packages/ui/.gitignore create mode 100644 mobile/packages/ui/lib/src/components/html_text.dart create mode 100644 mobile/packages/ui/test/html_test.dart create mode 100644 mobile/packages/ui/test/test_utils.dart diff --git a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart b/mobile/lib/presentation/pages/dev/ui_showcase.page.dart index 37c412a0e9..a07c7fc19c 100644 --- a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart +++ b/mobile/lib/presentation/pages/dev/ui_showcase.page.dart @@ -91,6 +91,18 @@ class ImmichUIShowcasePage extends StatelessWidget { children: [ImmichTextInput(label: "Title", hintText: "Enter a title")], ), ), + const _ComponentTitle("HtmlText"), + ImmichHtmlText( + 'This is an example of HTML text with bold and links.', + linkHandlers: { + 'link': () { + context.showSnackBar(const SnackBar(content: Text('Link tapped!'))); + }, + 'test-link': () { + context.showSnackBar(const SnackBar(content: Text('Test-link tapped!'))); + }, + }, + ), ], ), ), diff --git a/mobile/packages/ui/.gitignore b/mobile/packages/ui/.gitignore new file mode 100644 index 0000000000..adf09d9f51 --- /dev/null +++ b/mobile/packages/ui/.gitignore @@ -0,0 +1,12 @@ +# Build artifacts +build/ + +# Platform-specific files are not needed as this is a Flutter UI package +android/ +ios/ + +# Test cache and generated files +.dart_tool/ +.packages +.flutter-plugins +.flutter-plugins-dependencies \ No newline at end of file diff --git a/mobile/packages/ui/lib/immich_ui.dart b/mobile/packages/ui/lib/immich_ui.dart index 9f2a886ab3..909ab65bce 100644 --- a/mobile/packages/ui/lib/immich_ui.dart +++ b/mobile/packages/ui/lib/immich_ui.dart @@ -1,5 +1,6 @@ export 'src/components/close_button.dart'; export 'src/components/form.dart'; +export 'src/components/html_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/html_text.dart b/mobile/packages/ui/lib/src/components/html_text.dart new file mode 100644 index 0000000000..72b54b8da5 --- /dev/null +++ b/mobile/packages/ui/lib/src/components/html_text.dart @@ -0,0 +1,189 @@ +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 fa0b425230..c74422dd97 100644 --- a/mobile/packages/ui/pubspec.lock +++ b/mobile/packages/ui/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" characters: dependency: transitive description: @@ -9,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" collection: dependency: transitive description: @@ -17,11 +41,72 @@ 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: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_test: + dependency: "direct dev" + 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: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -34,15 +119,71 @@ 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: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" vector_math: dependency: transitive description: @@ -51,5 +192,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" sdks: dart: ">=3.8.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/mobile/packages/ui/pubspec.yaml b/mobile/packages/ui/pubspec.yaml index 47b9a9dd8a..d23f34f1a7 100644 --- a/mobile/packages/ui/pubspec.yaml +++ b/mobile/packages/ui/pubspec.yaml @@ -7,6 +7,11 @@ environment: dependencies: flutter: sdk: flutter + html: ^0.15.6 + +dev_dependencies: + flutter_test: + sdk: flutter flutter: uses-material-design: true \ No newline at end of file diff --git a/mobile/packages/ui/test/html_test.dart b/mobile/packages/ui/test/html_test.dart new file mode 100644 index 0000000000..27f68ff66c --- /dev/null +++ b/mobile/packages/ui/test/html_test.dart @@ -0,0 +1,266 @@ +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 'test_utils.dart'; + +/// Text.rich creates a nested structure: root -> wrapper -> 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!; + } + } + return []; +} + +TextSpan _findSpan(List spans, String text) { + return spans.firstWhere( + (span) => span is TextSpan && span.text == text, + orElse: () => throw StateError('No span found with text: "$text"'), + ) as TextSpan; +} + +String _concatenateText(List spans) { + return spans.whereType().map((s) => s.text ?? '').join(); +} + +void _triggerTap(TextSpan span) { + final recognizer = span.recognizer; + if (recognizer is TapGestureRecognizer) { + recognizer.onTap?.call(); + } +} + +void main() { + group('ImmichHtmlText', () { + testWidgets('renders plain text without HTML tags', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('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( + 'Test text', + style: TextStyle( + fontSize: 16, + color: Colors.purple, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + + final text = tester.widget(find.byType(Text)); + final richText = text.textSpan as TextSpan; + + expect(richText.style?.fontSize, 16); + expect(richText.style?.color, Colors.purple); + expect(text.textAlign, TextAlign.center); + expect(text.maxLines, 2); + expect(text.overflow, TextOverflow.ellipsis); + }); + + testWidgets('handles text with special characters', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('Text with & < > " \' characters'), + ); + + expect(find.byType(RichText), findsOneWidget); + + final spans = _getContentSpans(tester); + expect(_concatenateText(spans), 'Text with & < > " \' characters'); + }); + + group('bold', () { + testWidgets('renders bold text with tag', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('This is bold text'), + ); + + final spans = _getContentSpans(tester); + final boldSpan = _findSpan(spans, 'bold'); + + 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( + 'This is a custom link text', + linkHandlers: {'link': () {}}, + ), + ); + + final spans = _getContentSpans(tester); + final linkSpan = _findSpan(spans, 'custom link'); + + expect(linkSpan.style?.decoration, TextDecoration.underline); + expect(linkSpan.recognizer, isA()); + }); + + testWidgets('handles link tap with callback', (tester) async { + var linkTapped = false; + + await tester.pumpTestWidget( + ImmichHtmlText( + 'Tap here', + linkHandlers: {'link': () => linkTapped = true}, + ), + ); + + final spans = _getContentSpans(tester); + final linkSpan = _findSpan(spans, 'here'); + expect(linkSpan.recognizer, isA()); + + _triggerTap(linkSpan); + expect(linkTapped, isTrue); + }); + + testWidgets('handles custom prefixed link tags', (tester) async { + await tester.pumpTestWidget( + ImmichHtmlText( + 'Refer to docs and other', + linkHandlers: { + 'docs-link': () {}, + 'other-link': () {}, + }, + ), + ); + + final spans = _getContentSpans(tester); + final docsSpan = _findSpan(spans, 'docs'); + final otherSpan = _findSpan(spans, 'other'); + + expect(docsSpan.style?.decoration, TextDecoration.underline); + expect(otherSpan.style?.decoration, TextDecoration.underline); + }); + + testWidgets('applies custom link style', (tester) async { + const customLinkStyle = TextStyle( + color: Colors.red, + decoration: TextDecoration.overline, + ); + + await tester.pumpTestWidget( + ImmichHtmlText( + 'Click here', + linkStyle: customLinkStyle, + linkHandlers: {'link': () {}}, + ), + ); + + final spans = _getContentSpans(tester); + final linkSpan = _findSpan(spans, 'here'); + + expect(linkSpan.style?.color, Colors.red); + expect(linkSpan.style?.decoration, TextDecoration.overline); + }); + + testWidgets('link without handler renders but is not tappable', (tester) async { + await tester.pumpTestWidget( + ImmichHtmlText( + 'Link without handler: click me', + linkHandlers: {'other-link': () {}}, + ), + ); + + final spans = _getContentSpans(tester); + final linkSpan = _findSpan(spans, 'click me'); + + expect(linkSpan.style?.decoration, TextDecoration.underline); + expect(linkSpan.recognizer, isNull); + }); + + testWidgets('handles multiple links with different handlers', (tester) async { + var firstLinkTapped = false; + var secondLinkTapped = false; + + await tester.pumpTestWidget( + ImmichHtmlText( + 'Go to docs or help', + linkHandlers: { + 'docs-link': () => firstLinkTapped = true, + 'help-link': () => secondLinkTapped = true, + }, + ), + ); + + final spans = _getContentSpans(tester); + final docsSpan = _findSpan(spans, 'docs'); + final helpSpan = _findSpan(spans, 'help'); + + _triggerTap(docsSpan); + expect(firstLinkTapped, isTrue); + expect(secondLinkTapped, isFalse); + + _triggerTap(helpSpan); + expect(secondLinkTapped, isTrue); + }); + }); + }); +} diff --git a/mobile/packages/ui/test/test_utils.dart b/mobile/packages/ui/test/test_utils.dart new file mode 100644 index 0000000000..42cc74da87 --- /dev/null +++ b/mobile/packages/ui/test/test_utils.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +extension WidgetTesterExtension on WidgetTester { + /// Pumps a widget wrapped in MaterialApp and Scaffold for testing. + Future pumpTestWidget(Widget widget) { + return pumpWidget(MaterialApp(home: Scaffold(body: widget))); + } +}