Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .fvmrc
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"flutter": "3.41.6"
}
}
1 change: 0 additions & 1 deletion .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ jobs:
# TODO clean up the dart analyzer info stuff
# dart analyze --fatal-infos
dart analyze
dart run custom_lint

- name: Run all tests and generate coverage information in coverage/lcov.info
run: flutter test --coverage
Expand Down
114 changes: 114 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:app4training/l10n/generated/app_localizations.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:app4training/routes/routes.dart';
Expand All @@ -8,8 +9,121 @@ import 'data/app_language.dart';
import 'data/globals.dart';
import 'design/theme.dart';

/// Installs a global [FlutterError.onError] wrapper that suppresses the
/// known non-fatal framework assertions produced by `flutter_html` /
/// `flutter_html_table` when rendering pages that contain tables, and
/// forwards everything else unchanged to the previously-installed
/// handler (typically [FlutterError.presentError]).
///
/// Four assertion variants have been observed in the wild, all
/// originating from the `LayoutBuilder` / `LayoutGrid` / `WidgetSpan`
/// composition that `TableHtmlExtension.build` constructs for `<table>`
/// rendering:
///
/// 1. Paint-time `RenderBox was not laid out` — fires from
/// `PipelineOwner.flushPaint` → `RenderCSSBox.paint` →
/// `RenderDecoratedBox.paint` → `RenderBox.size`. Stack contains
/// `flutter_html/` and `flutter_layout_grid/` frames.
///
/// 2. Semantics-pass `RenderBox was not laid out` — fires from
/// `PipelineOwner.flushSemantics` →
/// `_SemanticsGeometry.computeChildGeometry` →
/// `RenderBox.semanticBounds` → `RenderBox.size`. Reported via
/// `SchedulerBinding._invokeFrameCallback` with
/// `library: 'scheduler library'`. The stack is **pure framework
/// code** with no package frames, because the semantics walk
/// traverses the `_RenderObjectSemantics` tree directly without
/// going through widget code — so matching on package paths alone
/// would miss this variant.
///
/// 3. Layout-time `RenderBox.size accessed in ...computeDryBaseline`
/// — fires from `RenderParagraph.performLayout` →
/// `layoutInlineChildren` → child `performLayout` reading `.size`
/// during a dry-baseline computation. Stack contains
/// `flutter_html/src/css_box_widget.dart` frames.
///
/// 4. Layout-time `'child!.hasSize' is not true` — fires from
/// `RenderAligningShiftedBox.alignChild` while laying out a
/// `Container` constructed in `flutter_html_table.dart:261`.
/// Stack contains `flutter_html/` and `flutter_layout_grid/`
/// frames.
///
/// All four are non-fatal: the page renders, users can interact
/// normally, selection still works outside tables. They flood logs
/// (hundreds per page load) which drowns out any real errors, which is
/// why we filter them.
///
/// Why this lives here and not in the widget layer:
/// 1. The assertions are not reproducible in `flutter_test`, even
/// with `tester.ensureSemantics()` + a phone-sized surface, so we
/// cannot regression-test a widget-level fix;
/// 2. The root cause is inside third-party package code
/// (`flutter_html_table` 3.0.0). Wrapping tables in
/// `SelectionContainer.disabled` (mirroring Flutter's own
/// `raw_tooltip.dart:813-815` pattern) did not help and also
/// broke text selection across the whole page;
/// 3. The real fix is a newer upstream release. Until then this
/// filter keeps the logs usable.
///
/// Matching strategy — an error is suppressed only if BOTH:
/// (a) the exception message contains one of the four known
/// signatures (`"RenderBox was not laid out"`, `"computeDryBaseline"`,
/// `"renderBoxDoingDryBaseline"`, or `"'child!.hasSize'"`), AND
/// (b) the stack either passes through `flutter_html/` or
/// `flutter_layout_grid/` package code (covers variants 1, 3, 4)
/// OR contains `"flushSemantics"` (covers variant 2, whose stack
/// is entirely framework code).
///
/// Any error that doesn't satisfy BOTH conditions is forwarded to the
/// previous handler unchanged, so unrelated regressions are not masked.
///
/// On the first suppression per app run we emit a single breadcrumb via
/// [debugPrint] so developers inspecting logs can see that the filter
/// is active. Subsequent suppressions are silent to avoid log spam.
///
/// See also: `Task Progress.md` §5 for the full investigation history,
/// including the diagnostic logging session that identified all four
/// variants.
void _installHtmlTableSemanticsFilter() {
final FlutterExceptionHandler? previousHandler = FlutterError.onError;
var suppressedBreadcrumbEmitted = false;
FlutterError.onError = (FlutterErrorDetails details) {
final String exceptionText = details.exception.toString();
final String stackText = details.stack?.toString() ?? '';
final bool exceptionMatchesKnownSignature =
exceptionText.contains('RenderBox was not laid out') ||
exceptionText.contains('computeDryBaseline') ||
exceptionText.contains('renderBoxDoingDryBaseline') ||
exceptionText.contains("'child!.hasSize'");
final bool stackMatchesHtmlOrSemanticsPath =
stackText.contains('flutter_html/') ||
stackText.contains('flutter_layout_grid/') ||
stackText.contains('flushSemantics');
final bool isKnownHtmlTableAssertion =
exceptionMatchesKnownSignature && stackMatchesHtmlOrSemanticsPath;
if (isKnownHtmlTableAssertion) {
if (!suppressedBreadcrumbEmitted) {
suppressedBreadcrumbEmitted = true;
debugPrint(
'Suppressing known flutter_html_table non-fatal framework '
'assertions (see Task Progress.md §5 and main.dart '
'_installHtmlTableSemanticsFilter for details). Subsequent '
'suppressions are silent.',
);
}
return;
}
if (previousHandler != null) {
previousHandler(details);
} else {
FlutterError.presentError(details);
}
};
}

void main() async {
WidgetsFlutterBinding.ensureInitialized();
_installHtmlTableSemanticsFilter();
final prefs = await SharedPreferences.getInstance();
final packageInfo = await PackageInfo.fromPlatform();

Expand Down
195 changes: 139 additions & 56 deletions lib/widgets/html_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,69 +12,139 @@ class HtmlView extends StatelessWidget {

/// left-to-right or right-to-left (LTR / RTL)?
final TextDirection direction;

const HtmlView(this.content, this.direction, {super.key});

@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: SingleChildScrollView(
child: Column(
padding: const EdgeInsets.all(8),
child: SingleChildScrollView(
child: Column(
children: [
SelectionArea(
child: Directionality(
textDirection: direction,
// child: Html(
// data: content,
child: Html.fromDom(
document: sanitize(
content,
MediaQuery.of(context).platformBrightness ==
Brightness.dark),
extensions: const [TableHtmlExtension()],
style: {
"body": Style(fontSize: FontSize(15)),
"table": Style(
// set table width, otherwise they're broken
width: Width(
MediaQuery.of(context).size.width - 50)),
"td": Style(
padding: const EdgeInsets.fromLTRB(5, 3, 5, 3)
.htmlPadding),
"th": Style(
textAlign: TextAlign.center,
verticalAlign: VerticalAlign.top),
"h1": Style(
margin:
Margins(top: Margin(0), bottom: Margin(0))),
"h2": Style(
margin:
Margins(top: Margin(12), bottom: Margin(5))),
"h3": Style(
margin:
Margins(top: Margin(10), bottom: Margin(3))),
"li": Style(
margin:
Margins(top: Margin(3), bottom: Margin(3))),
"p": Style(
margin:
Margins(top: Margin(3), bottom: Margin(3))),
"ul": Style(
margin:
Margins(top: Margin(0), bottom: Margin(0))),
// TODO: reduce left padding/margin of <li> items
// But this doesn't seem to work in flutter_html-3.0.0-beta2
/* "li": Style(
padding: HtmlPaddings.zero, margin: Margins.zero) */
},
onAnchorTap: (url, _, __) {
debugPrint("Link tapped: $url");
if (url != null) {
Navigator.pushNamed(context, '/view$url');
}
})))
child: Directionality(
textDirection: direction,
// child: Html(
// data: content,
child: Html.fromDom(
document: sanitize(
content,
MediaQuery.of(context).platformBrightness ==
Brightness.dark,
),
extensions: [
// Order matters: TagWrapExtension must come BEFORE
// TableHtmlExtension so it matches <table> first
// during the preparing step. It then delegates the
// inner build to TableHtmlExtension via
// prepareFromExtension(extensionsToIgnore: {this})
// and wraps the resulting table widget in a
// horizontal scroll view. Without this ordering,
// TableHtmlExtension wins the match and the wrap
// is never applied — leaving wide tables to
// overflow or hit "RenderBox was not laid out".
//
// Known remaining issue: on real devices the
// semantics pass logs a non-fatal per-frame
// "RenderBox was not laid out: RenderParagraph"
// assertion during PipelineOwner.flushSemantics,
// originating deep inside the
// LayoutGrid/LayoutBuilder cell tree built by
// TableHtmlExtension. Wrapping the table in
// SelectionContainer.disabled was tried as a
// workaround (mirroring raw_tooltip.dart:813-815)
// but did NOT fix the assertion and broke text
// selection across the whole page, so it was
// reverted. Page still renders fine; the spam is
// cosmetic in logs.
TagWrapExtension(
tagsToWrap: const {'table'},
builder: (child) => _HorizontalTableScroll(child: child),
),
const TableHtmlExtension(),
],
style: {
"body": Style(fontSize: FontSize(15)),
"td": Style(
padding:
const EdgeInsets.fromLTRB(5, 3, 5, 3).htmlPadding,
),
"th": Style(
textAlign: TextAlign.center,
verticalAlign: VerticalAlign.top,
),
"h1": Style(
margin: Margins(top: Margin(0), bottom: Margin(0)),
),
"h2": Style(
margin: Margins(top: Margin(12), bottom: Margin(5)),
),
"h3": Style(
margin: Margins(top: Margin(10), bottom: Margin(3)),
),
"li": Style(
margin: Margins(top: Margin(3), bottom: Margin(3)),
padding: HtmlPaddings.zero,
),
"p": Style(
margin: Margins(top: Margin(3), bottom: Margin(3)),
),
"ul": Style(
margin: Margins(top: Margin(0), bottom: Margin(0)),
),
},
onAnchorTap: (url, _, __) {
debugPrint("Link tapped: $url");
if (url != null) {
Navigator.pushNamed(context, '/view$url');
}
},
),
),
),
],
)));
),
),
);
}
}

/// Wraps a rendered `<table>` in a horizontal scroll view with an
/// always-visible scrollbar. An explicit [ScrollController] is needed
/// because the scroll view sits inside a `WidgetSpan` (via
/// [TagWrapExtension]) where [PrimaryScrollController] does not apply.
/// [Scrollbar.thumbVisibility] is forced on so users can see at a glance
/// when a table extends beyond the screen and needs to be scrolled.
class _HorizontalTableScroll extends StatefulWidget {
const _HorizontalTableScroll({required this.child});

final Widget child;

@override
State<_HorizontalTableScroll> createState() => _HorizontalTableScrollState();
}

class _HorizontalTableScrollState extends State<_HorizontalTableScroll> {
final ScrollController _controller = ScrollController();

@override
void dispose() {
_controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Scrollbar(
controller: _controller,
thumbVisibility: true,
child: SingleChildScrollView(
controller: _controller,
scrollDirection: Axis.horizontal,
child: widget.child,
),
);
}
}

Expand Down Expand Up @@ -142,6 +212,19 @@ htmldom.Document sanitize(String inputHtml, bool isDarkMode) {
for (var element in dom.querySelectorAll('th')) {
element.attributes.remove('style');
}
// e.g. Time_with_God has <table style="width:100%">. flutter_html 3.0.0
// does NOT resolve percent widths against the containing block (see
// CssBoxWidget._computeSize and Normalize.normalize in
// flutter_html-3.0.0/lib/src/css_box_widget.dart): it uses the raw
// numeric value regardless of unit, so `width: 100%` is interpreted as
// literally 100 px and the table overflows by hundreds of pixels.
// Strip style and width attributes from <table> so it falls back to
// Width.auto() and our horizontal-scroll wrapper can size the table
// to its intrinsic content width.
for (var element in dom.querySelectorAll('table')) {
element.attributes.remove('style');
element.attributes.remove('width');
}

// For all worksheets with subtitles (e.g. "Overcoming Colored Lenses"):
// Replace <p><span style="font-size:125%"><i><b>...</b></i></span></p>
Expand All @@ -157,7 +240,7 @@ htmldom.Document sanitize(String inputHtml, bool isDarkMode) {

// For God's Story (five fingers):
// Remove <div style="margin-left:25px"
/* for (var element in dom.querySelectorAll('div')) {
/* for (var element in dom.querySelectorAll('div')) {
if (element.attributes['style'] == 'margin-left:25px') {
element.attributes['style'] = '';
}
Expand Down
Loading
Loading