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)>(.*?)\1>', 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)));
+ }
+}