mirror of
https://github.com/immich-app/immich.git
synced 2026-01-31 01:04:49 -08:00
Compare commits
1 Commits
v2.5.1
...
feat/html-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
992fdbd3a6 |
@@ -91,6 +91,18 @@ class ImmichUIShowcasePage extends StatelessWidget {
|
||||
children: [ImmichTextInput(label: "Title", hintText: "Enter a title")],
|
||||
),
|
||||
),
|
||||
const _ComponentTitle("HtmlText"),
|
||||
ImmichHtmlText(
|
||||
'This is an <b>example</b> of <test-link>HTML text</test-link> with <b>bold</b> and <link>links</link>.',
|
||||
linkHandlers: {
|
||||
'link': () {
|
||||
context.showSnackBar(const SnackBar(content: Text('Link tapped!')));
|
||||
},
|
||||
'test-link': () {
|
||||
context.showSnackBar(const SnackBar(content: Text('Test-link tapped!')));
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
12
mobile/packages/ui/.gitignore
vendored
Normal file
12
mobile/packages/ui/.gitignore
vendored
Normal file
@@ -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
|
||||
@@ -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';
|
||||
|
||||
189
mobile/packages/ui/lib/src/components/html_text.dart
Normal file
189
mobile/packages/ui/lib/src/components/html_text.dart
Normal file
@@ -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 <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).primaryColor,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -7,6 +7,11 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
html: ^0.15.6
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
266
mobile/packages/ui/test/html_test.dart
Normal file
266
mobile/packages/ui/test/html_test.dart
Normal file
@@ -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<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!;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
TextSpan _findSpan(List<InlineSpan> 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<InlineSpan> spans) {
|
||||
return spans.whereType<TextSpan>().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 <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(
|
||||
'Test text',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.purple,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
|
||||
final text = tester.widget<Text>(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 <b> tag', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText('This is <b>bold</b> 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 <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(
|
||||
'This is a <link>custom link</link> text',
|
||||
linkHandlers: {'link': () {}},
|
||||
),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
final linkSpan = _findSpan(spans, 'custom link');
|
||||
|
||||
expect(linkSpan.style?.decoration, TextDecoration.underline);
|
||||
expect(linkSpan.recognizer, isA<TapGestureRecognizer>());
|
||||
});
|
||||
|
||||
testWidgets('handles link tap with callback', (tester) async {
|
||||
var linkTapped = false;
|
||||
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
'Tap <link>here</link>',
|
||||
linkHandlers: {'link': () => linkTapped = true},
|
||||
),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
final linkSpan = _findSpan(spans, 'here');
|
||||
expect(linkSpan.recognizer, isA<TapGestureRecognizer>());
|
||||
|
||||
_triggerTap(linkSpan);
|
||||
expect(linkTapped, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('handles custom prefixed link tags', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
'Refer to <docs-link>docs</docs-link> and <other-link>other</other-link>',
|
||||
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 <link>here</link>',
|
||||
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: <link>click me</link>',
|
||||
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-link>docs</docs-link> or <help-link>help</help-link>',
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
9
mobile/packages/ui/test/test_utils.dart
Normal file
9
mobile/packages/ui/test/test_utils.dart
Normal file
@@ -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<void> pumpTestWidget(Widget widget) {
|
||||
return pumpWidget(MaterialApp(home: Scaffold(body: widget)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user