From 4f64f929e0a9161ee4b474d9849d7f96295a51ab Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Mon, 10 Aug 2026 18:26:31 -0300 Subject: [PATCH 1/5] feat(widgets): create new custom widget to allow color inversion --- lib/widgets/invertible_image_builtin.dart | 54 +++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 lib/widgets/invertible_image_builtin.dart diff --git a/lib/widgets/invertible_image_builtin.dart b/lib/widgets/invertible_image_builtin.dart new file mode 100644 index 0000000..a16df2b --- /dev/null +++ b/lib/widgets/invertible_image_builtin.dart @@ -0,0 +1,54 @@ +// ImageBuiltIn is not exported by package:flutter_html/flutter_html.dart, +// only reachable via this implementation import. flutter_html's own +// ImageExtension helper subclasses it through the same import, but its +// builder API *replaces* the built-in rendering instead of wrapping it, +// so it can't be used to filter the widget the built-in builds. +// ignore_for_file: implementation_imports +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_html/src/builtins/image_builtin.dart'; + +/// Renders `` elements exactly like flutter_html's built-in image +/// renderer, optionally drawing the image through a hue-preserving color +/// inversion for dark mode: black becomes white, grays flip lightness, +/// transparency passes through unchanged and colored artwork keeps its +/// hue with lightness flipped. +/// +/// With [invert] false the built-in rendering is returned unchanged — +/// no wrapper widget is added at all. +class InvertibleImageBuiltIn extends ImageBuiltIn { + final bool invert; + + const InvertibleImageBuiltIn({required this.invert}); + + @override + InlineSpan build(ExtensionContext context) { + final span = super.build(context); + if (!invert || span is! WidgetSpan) { + // Light mode stays untouched; so do images the built-in renderer + // can't decode (super returns a plain TextSpan with the alt text). + return span; + } + return WidgetSpan( + alignment: span.alignment, + baseline: span.baseline, + style: span.style, + child: ColorFiltered( + colorFilter: const ColorFilter.matrix(_invertKeepHueMatrix), + child: span.child, + ), + ); + } +} + +/// Color inversion that keeps hues, like CSS +/// `filter: invert(1) hue-rotate(180deg)` ("smart invert"): +/// black -> white, grays invert exactly (each row sums to -1, plus the +/// 255 offset), alpha is untouched (last row) and colors keep their hue +/// with flipped lightness. +const List _invertKeepHueMatrix = [ + 0.574, -1.430, -0.144, 0, 255, // + -0.426, -0.430, -0.144, 0, 255, // + -0.426, -1.430, 0.856, 0, 255, // + 0, 0, 0, 1, 0, +]; From b44d20857aacf409756293075a907372bac6262c Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Mon, 10 Aug 2026 18:27:12 -0300 Subject: [PATCH 2/5] refactor(widgets): swap to new img color invertable widget --- lib/widgets/html_view.dart | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/widgets/html_view.dart b/lib/widgets/html_view.dart index b02fd3f..b34faf5 100644 --- a/lib/widgets/html_view.dart +++ b/lib/widgets/html_view.dart @@ -1,3 +1,4 @@ +import 'package:app4training/widgets/invertible_image_builtin.dart'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html_table/flutter_html_table.dart'; @@ -17,6 +18,7 @@ class HtmlView extends StatelessWidget { @override Widget build(BuildContext context) { + final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; return Padding( padding: const EdgeInsets.all(8), child: SingleChildScrollView( @@ -28,11 +30,11 @@ class HtmlView extends StatelessWidget { // child: Html( // data: content, child: Html.fromDom( - document: sanitize( - content, - MediaQuery.of(context).platformBrightness == - Brightness.dark, - ), + // The theme's brightness (not the platform brightness) + // is the canonical dark-mode source for this widget: + // the sanitize pass and the image inversion must flip + // together with the widget colors. + document: sanitize(content, isDarkMode), extensions: [ // Order matters: TagWrapExtension must come BEFORE // TableHtmlExtension so it matches first @@ -63,6 +65,15 @@ class HtmlView extends StatelessWidget { builder: (child) => _HorizontalTableScroll(child: child), ), const TableHtmlExtension(), + // Renders images like the built-in renderer, but + // inverts their colors (keeping hue) in dark mode so + // black-on-transparent drawings stay visible. + // Deliberately a subclass of the built-in image + // renderer and not a TagWrapExtension: at build time + // TagWrapExtension matches any wrapper element, + // which would collide with the table wrapper above + // under first-match-wins dispatch. + InvertibleImageBuiltIn(invert: isDarkMode), ], style: { "body": Style(fontSize: FontSize(15)), From d3c5cd96c3b9ec2be8b71eef0876900abd4da2df Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Mon, 10 Aug 2026 18:27:34 -0300 Subject: [PATCH 3/5] build(pubspec): update build files --- pubspec.lock | 16 ++++++++-------- pubspec.yaml | 6 ++---- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index fa4dfa4..e66bfc7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -496,10 +496,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -893,26 +893,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.17" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 45dbdb6..5f69ca7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: 4training.net app # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.8.7+11 +version: 0.8.8+1 environment: sdk: ^3.7.0 @@ -54,7 +54,6 @@ dependencies: share_plus: ^13.0.0 open_filex: ^4.4.0 - dev_dependencies: flutter_test: sdk: flutter @@ -81,7 +80,6 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: - # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. From 730b05388d511723ffa1f14a48514567020cb687 Mon Sep 17 00:00:00 2001 From: Gabriel Sousa Date: Mon, 10 Aug 2026 18:27:49 -0300 Subject: [PATCH 4/5] build(ios): update auto generated iOS build files --- ios/Podfile.lock | 38 ------------------ ios/Runner.xcodeproj/project.pbxproj | 40 ++++++++++++++----- .../xcshareddata/xcschemes/Runner.xcscheme | 18 +++++++++ 3 files changed, 48 insertions(+), 48 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 74ad22f..823c7ec 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,64 +1,26 @@ PODS: - Flutter (1.0.0) - - integration_test (0.0.1): - - Flutter - open_filex (0.0.2): - Flutter - - package_info_plus (0.4.5): - - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - - share_plus (0.0.1): - - Flutter - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - url_launcher_ios (0.0.1): - - Flutter - workmanager_apple (0.0.1): - Flutter DEPENDENCIES: - Flutter (from `Flutter`) - - integration_test (from `.symlinks/plugins/integration_test/ios`) - open_filex (from `.symlinks/plugins/open_filex/ios`) - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - - share_plus (from `.symlinks/plugins/share_plus/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`) EXTERNAL SOURCES: Flutter: :path: Flutter - integration_test: - :path: ".symlinks/plugins/integration_test/ios" open_filex: :path: ".symlinks/plugins/open_filex/ios" - package_info_plus: - :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" - share_plus: - :path: ".symlinks/plugins/share_plus/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" workmanager_apple: :path: ".symlinks/plugins/workmanager_apple/ios" SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 - share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 PODFILE CHECKSUM: ec1ad48fafee7989ae02b93f14e4d03d1f0e05bb diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 78f239c..1f9431b 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -38,6 +39,7 @@ 4CC19D185F571E45AC7BB4FF /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -56,6 +58,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, D0190EC4023141C93A5318E8 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -84,6 +87,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -148,6 +152,9 @@ dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -176,6 +183,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -209,14 +219,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -365,7 +371,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 78WP6ZX639; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -375,7 +381,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.8.7; + MARKETING_VERSION = 0.8.8; PRODUCT_BUNDLE_IDENTIFIER = net.4training.app; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -502,7 +508,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 78WP6ZX639; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -512,7 +518,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.8.7; + MARKETING_VERSION = 0.8.8; PRODUCT_BUNDLE_IDENTIFIER = net.4training.app; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -533,7 +539,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 78WP6ZX639; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -543,7 +549,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.8.7; + MARKETING_VERSION = 0.8.8; PRODUCT_BUNDLE_IDENTIFIER = net.4training.app; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -581,6 +587,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 9c12df5..5db441f 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + Date: Mon, 10 Aug 2026 18:29:23 -0300 Subject: [PATCH 5/5] test(html_view): add new test to validate dark mode inversion --- test/html_view_dark_mode_test.dart | 140 +++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 test/html_view_dark_mode_test.dart diff --git a/test/html_view_dark_mode_test.dart b/test/html_view_dark_mode_test.dart new file mode 100644 index 0000000..4cc896b --- /dev/null +++ b/test/html_view_dark_mode_test.dart @@ -0,0 +1,140 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:app4training/design/theme.dart'; +import 'package:app4training/widgets/html_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Verifies the dark-mode handling of worksheet content in HtmlView: +/// images get a hue-preserving color inversion in dark mode and are +/// left untouched in light mode. +void main() { + /// Mimics pageContentProvider: replace + /// with the base64-encoded image data, like the app does before + /// handing content to HtmlView. Missing files keep their raw src, + /// exactly like the app treats images that failed to download. + String inlineImages(String content, String filesDir) { + return content.replaceAllMapped(RegExp(r'src="files/([^.]+.png)"'), ( + match, + ) { + final image = File('$filesDir/${match.group(1)}'); + if (!image.existsSync()) return match.group(0)!; + return 'src="data:image/png;base64,' + '${base64Encode(image.readAsBytesSync())}"'; + }); + } + + /// God's Story (five fingers), German, with images inlined: + /// the same content the app renders on a real worksheet page. + /// The fixture has Hand_1.png..Hand_4.png but no Hand_5.png, so the + /// page contains four decodable images plus one unresolvable + /// reference — like the real app after a partly failed download. + String godsStoryContent() { + final html = + File( + 'test/assets-de/html-de-main/Gottes_Geschichte_(fünf_Finger).html', + ).readAsStringSync(); + return inlineImages(html, 'test/assets-de/html-de-main/files'); + } + + Future pumpHtmlView( + WidgetTester tester, + String content, { + required ThemeMode themeMode, + }) async { + // Phone-sized surface like on real devices — the default 800x600 + // test surface makes flutter_html's table layout trip baseline + // assertions on the God's Story page (see html_view_table_test.dart). + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + theme: lightTheme, + darkTheme: darkTheme, + themeMode: themeMode, + home: Scaffold(body: HtmlView(content, TextDirection.ltr)), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('dark theme applies color inversion to every worksheet image', ( + tester, + ) async { + await pumpHtmlView(tester, godsStoryContent(), themeMode: ThemeMode.dark); + // God's Story (images inside table cells) trips flutter_html's known + // non-fatal baseline assertions (see the comment in html_view.dart); + // drain them — this test is about the color filter, and the page + // renders fine on devices despite the log spam. + tester.takeException(); + + // Four decodable hand drawings; each rendered image must be + // drawn through the color filter. + expect(find.byType(Image), findsNWidgets(4)); + expect( + find.ancestor( + of: find.byType(Image), + matching: find.byType(ColorFiltered), + ), + findsNWidgets(4), + ); + }); + + testWidgets('light theme renders images without any color filter', ( + tester, + ) async { + await pumpHtmlView(tester, godsStoryContent(), themeMode: ThemeMode.light); + tester.takeException(); // see comment in the dark-mode test above + + // Images render as before: no filter anywhere in the tree. + expect(find.byType(Image), findsNWidgets(4)); + expect(find.byType(ColorFiltered), findsNothing); + }); + + for (final themeMode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('page with unresolvable image reference renders without error ' + '($themeMode)', (tester) async { + // An image that failed to download keeps its files/ src (see + // pageContentProvider) and must remain untouched in both themes. + await pumpHtmlView( + tester, + '

Text before

' + 'Hand 5.png' + '

Text after

', + themeMode: themeMode, + ); + expect(tester.takeException(), isNull); + expect(find.byType(ColorFiltered), findsNothing); + // The rest of the page is still rendered. + expect(find.textContaining('Text before'), findsOneWidget); + expect(find.textContaining('Text after'), findsOneWidget); + }); + } + + testWidgets('forced dark theme applies dark-mode box treatment even when the ' + 'platform brightness is light', (tester) async { + // The theme is the canonical brightness source for the whole widget: + // the ambient platform brightness in tests is light, so this test + // fails if the sanitize pass keys on platform brightness instead of + // the theme. + await pumpHtmlView( + tester, + '' + '
' + 'Boxed content
' + '', + themeMode: ThemeMode.dark, + ); + expect(tester.takeException(), isNull); + + Iterable boxesWithColor(Color color) => + tester.widgetList(find.byType(DecoratedBox)).where((w) { + final decoration = w.decoration; + return decoration is BoxDecoration && decoration.color == color; + }); + expect(boxesWithColor(const Color(0xFF090909)), isNotEmpty); + expect(boxesWithColor(const Color(0xFFF9F9F9)), isEmpty); + }); +}