import 'dart:ui' as ui show TextDirection; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../../l10n/strings.dart'; import '../../mews/document.dart'; import 'palettes.dart'; /// 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.scrollController, 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; final ScrollController? scrollController; @override State createState() => _MewsPageViewState(); } class _MewsPageViewState extends State { final _recognizers = {}; @override void didUpdateWidget(MewsPageView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.page != widget.page) { _disposeRecognizers(); } } @override void 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; 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), ]; return Directionality( textDirection: widget.page.direction == TextDirection.rtl ? ui.TextDirection.rtl : ui.TextDirection.ltr, // Page text is the reader's to copy; forms manage their own selection. child: SelectionArea( child: ColoredBox( color: palette.background, child: ListView( controller: widget.scrollController, padding: EdgeInsets.all(base), children: [ Center( child: ConstrainedBox( // max-width: 40em constraints: BoxConstraints(maxWidth: 40 * base), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: blocks, ), ), ), ], ), ), ), ); } 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), fontSize: base * 0.85), ), ); // Blocks ------------------------------------------------------------------- Widget _block(BuildContext context, MewsBlock block, double base) { final palette = widget.palette; final foreground = TextStyle(color: palette.foreground, fontSize: base, height: 1.5); return switch (block) { HeadingBlock(:final level, :final inline) => Padding( padding: EdgeInsets.only( top: level == 1 ? base * 0.5 : 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], ), ), ), ParagraphBlock(:final inline) => Padding( padding: EdgeInsets.only(bottom: 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: base * 2), child: Divider(color: palette.border, height: 1), ), 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.network( src, fit: BoxFit.scaleDown, errorBuilder: (_, _, _) => _imageFallback(alt, base, unavailable: true), ) : _imageFallback(alt, base), ), FormBlock() => Padding( padding: EdgeInsets.only(bottom: base), child: _MewsForm( form: block, foreground: foreground, onFormSubmit: widget.onFormSubmit, ), ), }; } 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 => 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 href, :final children) => TextSpan( style: TextStyle( color: widget.visitedUrls.contains(href) ? palette.visited : palette.link, decoration: TextDecoration.underline, ), recognizer: _recognizer(node), children: _inline(children, _recognizer(node))), }, ]; } 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, }); final FormBlock form; final TextStyle foreground; final void Function(FormBlock form, Map values) onFormSubmit; @override State<_MewsForm> createState() => _MewsFormState(); } class _MewsFormState extends State<_MewsForm> { final _controllers = {}; final _values = {}; @override void dispose() { 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; 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: FilledButton( onPressed: () => field.reset ? _reset(form) : _submit(form), child: Text( field.label ?? (field.reset ? strings.t('form-reset-default') : strings.t('form-submit-default')), ), ), ), ], ), ); } 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), ), 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) { 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; } } widget.onFormSubmit(form, 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; } } }); } }