import 'dart:async' show Timer; import 'dart:typed_data'; import 'package:flutter/services.dart' show HardwareKeyboard, KeyDownEvent, LogicalKeyboardKey; import 'dart:ui' as ui show TextDirection; import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart' show RenderAbstractViewport, RenderObject, RenderParagraph; import 'package:flutter/material.dart'; import '../../l10n/strings.dart'; import '../../application/fonts.dart'; import '../../mews/document.dart'; import 'palettes.dart'; /// How long the reader has to stop typing before a search box narrows its /// page. Long enough that a word is one query rather than one per letter. const searchTypingPause = Duration(milliseconds: 250); /// Renders a parsed [MewsPage] with the client's own presentation, following /// the measurements of the default stylesheet mews-0.1.css: a 40em text /// column, 1.5 line height, and the block margins authors expect. class MewsPageView extends StatefulWidget { const MewsPageView({ required this.page, required this.palette, required this.fontFamily, required this.fontScale, required this.visitedUrls, required this.onLinkTap, required this.onFormSubmit, this.onFormChange, this.fetchImageBytes, this.scrollController, this.onSaveQuotation, this.ownPage = false, super.key, }); final MewsPage page; final MewsPalette palette; final String fontFamily; final double fontScale; final Set visitedUrls; final void Function(String url) onLinkTap; final void Function(FormBlock form, Map values) onFormSubmit; /// Called as the reader types, so a search box can narrow its page without /// being submitted. Null on a page whose forms only answer when submitted. final void Function(FormBlock form, Map values)? onFormChange; /// The bytes of an image living on a protocol page, where no address exists /// that the platform could load from. Null when the page holds none. final Future Function(String url)? fetchImageBytes; final ScrollController? scrollController; /// Called with text the reader marked and chose to keep as a quotation. /// Null on a view that cannot keep one, such as a swipe peek. final void Function(String text)? onSaveQuotation; /// Whether this is one of the client's own pages. Those are built from the /// same blocks, but they are interface rather than an article, so they are /// laid out denser and wider, rows are divided instead of bulleted, and /// links read as controls rather than prose. final bool ownPage; @override State createState() => _MewsPageViewState(); } class _MewsPageViewState extends State { final _recognizers = {}; /// Images already asked for, so a rebuild shows the same one arriving /// instead of asking again. A failure is forgotten, so trying again works. final _images = >{}; /// The link the keyboard highlight sits on, or none; and the links of the /// page as it was last built, counted in reading order. int _focusedLink = -1; int _linkCount = 0; int _linkCounter = 0; final _linkHrefs = []; final _linkSpans = []; final _region = FocusNode(); /// Walks the keyboard through the page's links: Tab steps forward, /// Shift+Tab back, Enter opens the highlighted link, and Escape puts the /// highlight away. A Tab at either end leaves the walk to the focus /// traversal, so the chrome comes and goes as usual. KeyEventResult _onPageKey(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent) return KeyEventResult.ignored; final key = event.logicalKey; if (key == LogicalKeyboardKey.tab) { final forward = !HardwareKeyboard.instance.isShiftPressed; if (_focusedLink < 0) { if (!forward || _linkCount == 0) return KeyEventResult.ignored; _focusLink(0); return KeyEventResult.handled; } final next = _focusedLink + (forward ? 1 : -1); if (next < 0 || next >= _linkCount) { setState(() => _focusedLink = -1); return KeyEventResult.ignored; } _focusLink(next); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) { if (_focusedLink < 0) return KeyEventResult.ignored; widget.onLinkTap(_linkHrefs[_focusedLink]); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.escape && _focusedLink >= 0) { setState(() => _focusedLink = -1); return KeyEventResult.handled; } return KeyEventResult.ignored; } @override void didUpdateWidget(MewsPageView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.page != widget.page) { _disposeRecognizers(); // The links walked by the keyboard belong to the page, so a new page // starts with none highlighted. _focusedLink = -1; } } @override void dispose() { _region.dispose(); _disposeRecognizers(); super.dispose(); } void _disposeRecognizers() { for (final recognizer in _recognizers.values) { recognizer.dispose(); } _recognizers.clear(); } @override Widget build(BuildContext context) { final strings = StringsScope.of(context); final base = 17.0 * widget.fontScale; // body font-size: 106% final palette = widget.palette; _linkHrefs.clear(); _linkSpans.clear(); _linkCounter = 0; final blocks = [ if (!widget.page.isMews) _notice(strings.t('error-non-mews'), base, palette), for (final block in widget.page.body) _block(context, block, base), ]; _linkCount = _linkHrefs.length; return Focus( focusNode: _region, onKeyEvent: _onPageKey, // Pressing the page puts the keyboard focus on it, the way clicking a // document body does in a browser; a Listener watches without joining // the gesture arena, so links and scrolling keep their own taps. child: Listener( onPointerDown: (_) => _region.requestFocus(), child: Directionality( textDirection: widget.page.direction == TextDirection.rtl ? ui.TextDirection.rtl : ui.TextDirection.ltr, // Page text is the reader's to copy and to keep; forms manage // their own selection. The toolbar offers to keep the marked // text as a quotation, so the selection's text is carried here. child: SelectionArea( onSelectionChanged: (content) => _markedText = content?.plainText, contextMenuBuilder: (context, selectableRegion) => _selectionToolbar(context, selectableRegion), child: ColoredBox( color: palette.background, child: ListView( controller: widget.scrollController, padding: EdgeInsets.all(base), children: [ Center( child: ConstrainedBox( // max-width: 40em for an article; our own pages are lists, so // they get more room before they stop growing. constraints: BoxConstraints( maxWidth: (widget.ownPage ? 56 : 40) * base, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: blocks, ), ), ), ], ), ), ), ), ), ); } /// The text of the selection now on the page, if any, so the toolbar can /// offer to keep it. String? _markedText; /// The selection toolbar: the platform's own buttons, plus the choice to /// keep the marked text as a quotation when a place to keep it is given. Widget _selectionToolbar( BuildContext context, SelectableRegionState selectableRegion) { final items = selectableRegion.contextMenuButtonItems; final marked = _markedText?.trim(); if (widget.onSaveQuotation != null && marked != null && marked.isNotEmpty) { items.insert( 0, ContextMenuButtonItem( type: ContextMenuButtonType.custom, label: StringsScope.of(context).t('quotations-save'), onPressed: () { widget.onSaveQuotation!(marked); selectableRegion.hideToolbar(); selectableRegion.clearSelection(); }, ), ); } return AdaptiveTextSelectionToolbar.buttonItems( anchors: selectableRegion.contextMenuAnchors, buttonItems: items, ); } Widget _notice(String text, double base, MewsPalette palette) => Padding( padding: EdgeInsets.only(bottom: base), child: Text( text, style: TextStyle( color: palette.foreground.withValues(alpha: 0.7), fontFamily: widget.fontFamily, fontFamilyFallback: fallbackFor(widget.fontFamily), fontSize: base * 0.85, ), ), ); // Blocks ------------------------------------------------------------------- Widget _block(BuildContext context, MewsBlock block, double base) { final palette = widget.palette; final foreground = TextStyle( color: palette.foreground, fontFamily: widget.fontFamily, fontFamilyFallback: fallbackFor(widget.fontFamily), fontSize: base, height: 1.5, ); return switch (block) { HeadingBlock(:final level, :final inline) => Padding( padding: EdgeInsets.only( top: level == 1 ? base * 0.5 : (widget.ownPage ? base : base * 1.6), bottom: base * 0.5, ), child: Text.rich( TextSpan(children: _inline(inline)), style: foreground.copyWith( fontWeight: FontWeight.bold, height: 1.25, fontSize: base * const [1.6, 1.3, 1.1, 1.0, 1.0, 1.0][level - 1], // A section label on our own pages, not a document heading. color: widget.ownPage && level > 1 ? palette.foreground.withValues(alpha: 0.7) : null, letterSpacing: widget.ownPage && level > 1 ? 0.4 : null, ), ), ), ParagraphBlock(:final inline) => Padding( padding: EdgeInsets.only(bottom: widget.ownPage ? base * 0.35 : base), child: Text.rich( TextSpan(children: _inline(inline)), style: foreground, ), ), AddressBlock(:final inline) => Padding( padding: EdgeInsets.only(bottom: base), child: Text.rich( TextSpan(children: _inline(inline)), style: foreground.copyWith(fontStyle: FontStyle.italic), ), ), QuoteBlock(:final children) => Container( margin: EdgeInsets.only(bottom: base), decoration: BoxDecoration( border: BorderDirectional( start: BorderSide(color: palette.border, width: 3), ), ), padding: EdgeInsetsDirectional.only(start: base), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final child in children) _block(context, child, base), ], ), ), PreBlock(:final text) => Container( margin: EdgeInsets.only(bottom: base), color: palette.preBackground, padding: EdgeInsets.all(base * 0.75), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Text( text, style: foreground.copyWith( fontFamily: 'monospace', fontSize: base * 0.9, ), ), ), ), RuleBlock() => Padding( padding: EdgeInsets.symmetric( vertical: widget.ownPage ? base * 0.6 : base * 2, ), child: Divider(color: palette.border, height: 1), ), // Rows on our own pages: separated by a line rather than marked with a // bullet, so a list of bookmarks or settings reads as a list of rows. ListBlock(:final items) when widget.ownPage => Padding( padding: EdgeInsets.only(bottom: base * 0.35), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var i = 0; i < items.length; i++) ...[ if (i > 0) Divider(color: palette.border, height: 1), Padding( padding: EdgeInsets.only(top: base * 0.45), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final child in items[i].blocks) _block(context, child, base), ], ), ), ], ], ), ), ListBlock(:final ordered, :final start, :final items) => Padding( padding: EdgeInsets.only(bottom: base), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var i = 0; i < items.length; i++) Padding( padding: EdgeInsets.only(bottom: base * 0.25), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: base * 1.5, child: Text( ordered ? '${start + i}.' : '\u2022', style: foreground, ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final child in items[i].blocks) _block(context, child, base), ], ), ), ], ), ), ], ), ), DefinitionListBlock(:final items) => Padding( padding: EdgeInsets.only(bottom: base), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final item in items) ...[ Text.rich( TextSpan(children: _inline(item.term)), style: foreground.copyWith(fontWeight: FontWeight.bold), ), Padding( padding: EdgeInsetsDirectional.only( start: base * 1.5, bottom: base * 0.5, ), child: Text.rich( TextSpan(children: _inline(item.definition)), style: foreground, ), ), ], ], ), ), TableBlock(:final rows, :final caption) => Padding( padding: EdgeInsets.only(bottom: base), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (caption != null) Text( caption, style: foreground.copyWith(fontWeight: FontWeight.bold), ), Table( border: TableBorder.all(color: palette.border), defaultVerticalAlignment: TableCellVerticalAlignment.top, children: [ for (final row in rows) TableRow( children: [ for (final cell in row.cells) TableCell( child: Padding( padding: EdgeInsets.symmetric( horizontal: base * 0.6, vertical: base * 0.3, ), child: Text.rich( TextSpan(children: _inline(cell.inline)), style: foreground.copyWith( fontWeight: cell.header ? FontWeight.bold : null, ), ), ), ), ], ), ], ), ], ), ), ImageBlock(:final src, :final alt, :final allowed) => Padding( padding: EdgeInsets.only(bottom: base), child: allowed ? _image(src, alt, base) : _imageFallback(alt, base), ), FormBlock() => Padding( padding: EdgeInsets.only(bottom: base), // Keyed by what it submits to: a page rebuilt as the reader types in // this box is matched up with the box they are typing in, so the text // and the cursor stay where they are. key: ValueKey('form:${block.action}'), child: _MewsForm( form: block, foreground: foreground, onFormSubmit: widget.onFormSubmit, onFormChange: widget.onFormChange, ownPage: widget.ownPage, ), ), }; } /// An allowed image, from the platform on the web or from the protocol's /// own bytes on a capsule. A blocked or unreachable image falls back to its /// alt text, the way a missing image does. Widget _image(String src, String alt, double base) { final scheme = Uri.tryParse(src)?.scheme; if (scheme != 'gemini' && scheme != 'spartan') { return Image.network( src, fit: BoxFit.scaleDown, errorBuilder: (_, _, _) => _imageFallback(alt, base, unavailable: true), ); } final loader = widget.fetchImageBytes; if (loader == null) return _imageFallback(alt, base, unavailable: true); return FutureBuilder( future: _images.putIfAbsent(src, () { final arriving = loader(src); arriving.then((bytes) { if (bytes == null) _images.remove(src); }); return arriving; }), builder: (context, snapshot) => snapshot.connectionState != ConnectionState.done ? const SizedBox.shrink() : snapshot.data == null ? _imageFallback(alt, base, unavailable: true) : Image.memory( snapshot.data!, fit: BoxFit.scaleDown, errorBuilder: (_, _, _) => _imageFallback(alt, base, unavailable: true), ), ); } Widget _imageFallback(String alt, double base, {bool unavailable = false}) { final palette = widget.palette; final strings = StringsScope.of(context); final messageId = unavailable ? 'error-image-unavailable' : 'error-image-blocked'; final message = alt.isEmpty ? strings.t(messageId) : '${strings.t(messageId)} $alt'; return Container( width: double.infinity, padding: EdgeInsets.all(base * 0.75), decoration: BoxDecoration( border: Border.all(color: palette.border), color: palette.preBackground, ), child: Text( message, style: TextStyle(color: palette.foreground, fontSize: base * 0.9), ), ); } /// Builds the span tree. [linkRecognizer] carries the enclosing link's /// recognizer down to every glyph-owning span: hit-testing resolves to leaf /// spans only, so a recognizer on a parent span alone never fires. List _inline( List inline, [ TapGestureRecognizer? linkRecognizer, ]) { final palette = widget.palette; return [ for (final node in inline) switch (node) { MewsText(:final text) => TextSpan( text: text, recognizer: linkRecognizer, ), MewsLineBreak() => const TextSpan(text: '\n'), MewsEmphasis(:final style, :final children) => TextSpan( style: switch (style) { EmphasisStyle.strong => const TextStyle( fontWeight: FontWeight.bold, ), EmphasisStyle.emphasis when widget.ownPage => TextStyle( color: palette.foreground.withValues(alpha: 0.65), fontSize: 15.0 * widget.fontScale, ), EmphasisStyle.emphasis => const TextStyle( fontStyle: FontStyle.italic, ), EmphasisStyle.term => const TextStyle( fontStyle: FontStyle.italic, ), EmphasisStyle.quote => const TextStyle( fontStyle: FontStyle.italic, ), _ => const TextStyle(fontFamily: 'monospace'), }, children: _inline(children, linkRecognizer), ), MewsLink(:final children) => _linkSpan(node, children, palette), }, ]; } /// One link, in its place in the page's walk. Underlines mark links in /// prose; on our own pages they are controls, so weight carries them /// instead. The keyboard highlight underlines and tints whichever link /// Tab has landed on, wherever it is. TextSpan _linkSpan( MewsLink link, List children, MewsPalette palette, ) { final focused = _focusedLink == _linkCounter; _linkCounter++; _linkHrefs.add(link.href); return _recordedLink( TextSpan( style: TextStyle( color: widget.visitedUrls.contains(link.href) ? palette.visited : palette.link, decoration: focused || !widget.ownPage ? TextDecoration.underline : TextDecoration.none, fontWeight: widget.ownPage ? FontWeight.w500 : null, backgroundColor: focused ? palette.link.withValues(alpha: 0.14) : null, ), recognizer: _recognizer(link), children: _inline(children, _recognizer(link)), ), ); } /// Records a built link span, so the walk can find where it landed. TextSpan _recordedLink(TextSpan span) { _linkSpans.add(span); return span; } /// Moves the keyboard highlight and scrolls the page to keep the link in /// view, the way focus traversal carries the viewport along. void _focusLink(int index) { setState(() => _focusedLink = index); if (index >= 0) { WidgetsBinding.instance.addPostFrameCallback((_) => _revealFocusedLink()); } } void _revealFocusedLink() { if (!mounted || _focusedLink < 0) return; final controller = widget.scrollController; if (controller == null || !controller.hasClients) return; final span = _linkSpans[_focusedLink]; // The highlighted link is glyphs inside one of the page's paragraphs; // the walk finds which one, and where the link sits in it. var paragraph = renderParagraphOf(context.findRenderObject(), span); if (paragraph == null) return; final offset = _offsetWithin(paragraph.text, span); if (offset == null) return; final boxes = paragraph.getBoxesForSelection( TextSelection( baseOffset: offset, extentOffset: offset + span.toPlainText().length, ), ); if (boxes.isEmpty) return; // A link wrapped across lines shows every line of it, not just the first. final rect = Rect.fromLTRB( boxes.first.left, boxes.first.top, boxes.last.right, boxes.last.bottom, ); // The boxes are in the paragraph's own coordinates, which is what the // viewport asks for when revealing; the offset it answers with is where // to scroll to, and the walk rides there smoothly. final viewport = RenderAbstractViewport.of(paragraph); final reveal = viewport.getOffsetToReveal(paragraph, 0.5, rect: rect.inflate(8)); final position = controller.position; final target = reveal.offset .clamp(position.minScrollExtent, position.maxScrollExtent) .toDouble(); position.animateTo( target, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); } /// The rendered paragraph holding [span], searched among [root]'s /// descendants, or null when the page did not render one. RenderParagraph? renderParagraphOf(RenderObject? root, TextSpan span) { if (root == null) return null; RenderParagraph? found; void walk(RenderObject node) { if (found != null) return; if (node is RenderParagraph && _offsetWithin(node.text, span) != null) { found = node; return; } node.visitChildren(walk); } walk(root); return found; } /// Where [target] begins in the text of [root], counting characters down /// the span tree, or null when it is not among [root]'s descendants. /// Where [target] begins in the text of [root], counting characters down /// the span tree, or null when it is not among [root]'s descendants. /// TextSpan.visitChildren walks whole subtrees, so this walks the children /// itself: one level per step, one character count per sibling. int? _offsetWithin(InlineSpan root, InlineSpan target) { int? search(InlineSpan span, int start) { if (identical(span, target)) return start; final children = span is TextSpan ? span.children : null; if (children == null) return null; var cursor = start; for (final child in children) { final result = search(child, cursor); if (result != null) return result; cursor += child.toPlainText().length; } return null; } return search(root, 0); } TapGestureRecognizer _recognizer(MewsLink link) { return _recognizers.putIfAbsent(link, () { return TapGestureRecognizer()..onTap = () => widget.onLinkTap(link.href); }); } } /// A form rendered from the page model. Submitting sends the field values to /// the browser controller; reset restores the author's initial values. class _MewsForm extends StatefulWidget { const _MewsForm({ required this.form, required this.foreground, required this.onFormSubmit, this.onFormChange, this.ownPage = false, }); final FormBlock form; final TextStyle foreground; final void Function(FormBlock form, Map values) onFormSubmit; final void Function(FormBlock form, Map values)? onFormChange; /// On our own pages a form is a search box or a one-field prompt, so it sits /// on one line instead of filling the column like a page's own form. final bool ownPage; @override State<_MewsForm> createState() => _MewsFormState(); } class _MewsFormState extends State<_MewsForm> { final _controllers = {}; final _values = {}; /// Waits out [searchTypingPause] before the page is narrowed. Timer? _typing; /// A page that narrows as the reader types is rebuilt under the box they are /// typing in, and the rebuilt box carries what they typed, so the text is /// left alone. When the page changes it itself, as clearing a search does, /// the box follows. @override void didUpdateWidget(_MewsForm oldWidget) { super.didUpdateWidget(oldWidget); final wasValues = _modelValues(oldWidget.form); _modelValues(widget.form).forEach((name, value) { if (wasValues[name] == value) return; final controller = _controllers[name]; if (controller == null || controller.text == value) return; controller.value = TextEditingValue( text: value, selection: TextSelection.collapsed(offset: value.length), ); _values[name] = value; }); } Map _modelValues(FormBlock form) => { for (final field in form.fields) if (field case MewsTextField(:final name, :final value)) name: value, }; @override void dispose() { _typing?.cancel(); for (final controller in _controllers.values) { controller.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { final strings = StringsScope.of(context); final form = widget.form; final style = widget.foreground; Widget button(MewsSubmitField field) => FilledButton( onPressed: () => field.reset ? _reset(form) : _submit(form), child: Text( field.label ?? (field.reset ? strings.t('form-reset-default') : strings.t('form-submit-default')), style: TextStyle(fontFamily: style.fontFamily), ), ); if (widget.ownPage) { final inputs = form.fields.where((f) => f is! MewsSubmitField).toList(); return Form( child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ for (final field in inputs) if (field is! MewsHiddenField) Expanded(child: _field(field, style)) else _field(field, style), for (final field in form.fields.whereType()) Padding( padding: const EdgeInsetsDirectional.only(start: 8), child: button(field), ), ], ), ); } return Form( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final field in form.fields) _field(field, style), for (final field in form.fields.whereType()) Padding( padding: const EdgeInsets.only(top: 8), child: button(field), ), ], ), ); } Widget _field(MewsFormField field, TextStyle style) { Widget control = switch (field) { MewsTextField( :final name, :final password, :final value, :final maxLength, ) => TextFormField( controller: _controllers.putIfAbsent( name, () => TextEditingController( text: _values.putIfAbsent(name, () => value), ), ), obscureText: password, maxLength: maxLength, style: style, decoration: InputDecoration(labelText: field.label, isDense: true), onChanged: (text) => _changed(widget.form, name, text), ), MewsTextAreaField(:final name, :final value, :final rows) => TextFormField( controller: _controllers.putIfAbsent( name, () => TextEditingController( text: _values.putIfAbsent(name, () => value), ), ), minLines: rows, maxLines: rows, style: style, decoration: InputDecoration(labelText: field.label, isDense: true), ), MewsCheckboxField(:final name, :final checked) => CheckboxListTile( title: field.label == null ? null : Text(field.label!, style: style), value: _values.putIfAbsent(name, () => checked ? 'on' : '') == 'on', onChanged: (value) => setState(() => _values[name] = value == true ? 'on' : ''), ), MewsRadioField(:final name, :final value, :final checked) => RadioGroup( groupValue: _values.putIfAbsent(name, () => checked ? value : ''), onChanged: (value) => setState(() => _values[name] = value ?? ''), child: RadioListTile( value: value, title: field.label == null ? null : Text(field.label!, style: style), ), ), MewsSelectField(:final name, :final options) => options.isEmpty ? const SizedBox.shrink() : DropdownButtonFormField( initialValue: _values.putIfAbsent( name, () => options .firstWhere( (o) => o.selected, orElse: () => options.first, ) .value, ), items: [ for (final option in options) DropdownMenuItem( value: option.value, child: Text(option.value), ), ], onChanged: (value) => _values[name] = value ?? '', decoration: InputDecoration( labelText: field.label, isDense: true, ), ), MewsSubmitField() => const SizedBox.shrink(), MewsHiddenField() => const SizedBox.shrink(), }; return control; } void _submit(FormBlock form) { // Submitting says the reader is done typing, so it stands in for the // narrowing they were waiting on. _typing?.cancel(); widget.onFormSubmit(form, _valuesOf(form)); } /// Reports what is in the form once the reader pauses, so a search box /// narrows its page without being submitted. void _changed(FormBlock form, String name, String text) { _values[name] = text; final report = widget.onFormChange; if (report == null) return; _typing?.cancel(); _typing = Timer(searchTypingPause, () => report(form, _valuesOf(form))); } Map _valuesOf(FormBlock form) { final values = {}; for (final field in form.fields) { switch (field) { case MewsTextField(:final name): values[name] = _controllers[name]?.text ?? ''; case MewsTextAreaField(:final name): values[name] = _controllers[name]?.text ?? ''; case MewsCheckboxField(:final name, :final value): if (_values[name] == 'on') values[name] = value; case MewsRadioField(:final name, :final value): if (_values[name] == value) values[name] = value; case MewsSelectField(:final name): values[name] = _values[name] ?? ''; case MewsHiddenField(:final name, :final value): values[name] = value; case MewsSubmitField(): break; } } return values; } void _reset(FormBlock form) { setState(() { _values.clear(); for (final controller in _controllers.values) { controller.clear(); } // Restore the author's initial text field values. for (final field in form.fields) { switch (field) { case MewsTextField(:final name, :final value): _controllers[name]?.text = value; case MewsTextAreaField(:final name, :final value): _controllers[name]?.text = value; default: break; } } }); } }