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, 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; final ScrollController? scrollController; /// 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 = {}; @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 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, ), ), ), ], ), ), ), ); } 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, 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, 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.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, ownPage: widget.ownPage, ), ), }; } 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 href, :final children) => TextSpan( style: TextStyle( color: widget.visitedUrls.contains(href) ? palette.visited : palette.link, // Underlines mark links in prose; on our own pages they are // controls, so weight carries them instead. decoration: widget.ownPage ? TextDecoration.none : TextDecoration.underline, fontWeight: widget.ownPage ? FontWeight.w500 : null, ), 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, this.ownPage = false, }); final FormBlock form; final TextStyle foreground; final void Function(FormBlock form, Map values) onFormSubmit; /// 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 = {}; @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; 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), ), 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; } } }); } }