2026-10-11 22:56:56 +03:00
|
|
|
import 'dart:async' show Timer;
|
|
|
|
|
|
2026-10-11 22:07:25 +03:00
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
2026-10-11 22:32:44 +03:00
|
|
|
import 'package:flutter/services.dart'
|
|
|
|
|
show HardwareKeyboard, KeyDownEvent, LogicalKeyboardKey;
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
import 'dart:ui' as ui show TextDirection;
|
|
|
|
|
|
|
|
|
|
import 'package:flutter/gestures.dart';
|
2026-10-11 22:32:44 +03:00
|
|
|
import 'package:flutter/rendering.dart'
|
|
|
|
|
show RenderAbstractViewport, RenderObject, RenderParagraph;
|
2026-10-11 09:33:25 +03:00
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
|
|
|
|
import '../../l10n/strings.dart';
|
2026-10-11 14:24:06 +03:00
|
|
|
import '../../application/fonts.dart';
|
2026-10-11 09:33:25 +03:00
|
|
|
import '../../mews/document.dart';
|
|
|
|
|
import 'palettes.dart';
|
|
|
|
|
|
2026-10-11 22:56:56 +03:00
|
|
|
/// 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);
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
/// 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,
|
2026-10-11 22:56:56 +03:00
|
|
|
this.onFormChange,
|
2026-10-11 22:07:25 +03:00
|
|
|
this.fetchImageBytes,
|
2026-10-11 09:33:25 +03:00
|
|
|
this.scrollController,
|
2026-10-11 14:03:44 +03:00
|
|
|
this.ownPage = false,
|
2026-10-11 09:33:25 +03:00
|
|
|
super.key,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final MewsPage page;
|
|
|
|
|
final MewsPalette palette;
|
|
|
|
|
final String fontFamily;
|
|
|
|
|
final double fontScale;
|
|
|
|
|
final Set<String> visitedUrls;
|
|
|
|
|
final void Function(String url) onLinkTap;
|
|
|
|
|
final void Function(FormBlock form, Map<String, String> values) onFormSubmit;
|
2026-10-11 22:07:25 +03:00
|
|
|
|
2026-10-11 22:56:56 +03:00
|
|
|
/// 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<String, String> values)? onFormChange;
|
|
|
|
|
|
2026-10-11 22:07:25 +03:00
|
|
|
/// 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<Uint8List?> Function(String url)? fetchImageBytes;
|
2026-10-11 09:33:25 +03:00
|
|
|
final ScrollController? scrollController;
|
|
|
|
|
|
2026-10-11 14:03:44 +03:00
|
|
|
/// 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;
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
@override
|
|
|
|
|
State<MewsPageView> createState() => _MewsPageViewState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _MewsPageViewState extends State<MewsPageView> {
|
|
|
|
|
final _recognizers = <MewsLink, TapGestureRecognizer>{};
|
|
|
|
|
|
2026-10-11 22:07:25 +03:00
|
|
|
/// 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 = <String, Future<Uint8List?>>{};
|
|
|
|
|
|
2026-10-11 22:32:44 +03:00
|
|
|
/// 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 = <String>[];
|
|
|
|
|
final _linkSpans = <TextSpan>[];
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
@override
|
|
|
|
|
void didUpdateWidget(MewsPageView oldWidget) {
|
|
|
|
|
super.didUpdateWidget(oldWidget);
|
|
|
|
|
if (oldWidget.page != widget.page) {
|
|
|
|
|
_disposeRecognizers();
|
2026-10-11 22:32:44 +03:00
|
|
|
// The links walked by the keyboard belong to the page, so a new page
|
|
|
|
|
// starts with none highlighted.
|
|
|
|
|
_focusedLink = -1;
|
2026-10-11 09:33:25 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
2026-10-11 22:32:44 +03:00
|
|
|
_region.dispose();
|
2026-10-11 09:33:25 +03:00
|
|
|
_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;
|
|
|
|
|
|
2026-10-11 22:32:44 +03:00
|
|
|
_linkHrefs.clear();
|
|
|
|
|
_linkSpans.clear();
|
|
|
|
|
_linkCounter = 0;
|
2026-10-11 09:33:25 +03:00
|
|
|
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),
|
|
|
|
|
];
|
2026-10-11 22:32:44 +03:00
|
|
|
_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; 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,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
2026-10-11 09:33:25 +03:00
|
|
|
),
|
|
|
|
|
),
|
2026-10-11 22:32:44 +03:00
|
|
|
),
|
2026-10-11 11:48:24 +03:00
|
|
|
),
|
2026-10-11 09:33:25 +03:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _notice(String text, double base, MewsPalette palette) => Padding(
|
2026-10-11 22:32:44 +03:00
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-10-11 09:33:25 +03:00
|
|
|
|
|
|
|
|
// Blocks -------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
Widget _block(BuildContext context, MewsBlock block, double base) {
|
|
|
|
|
final palette = widget.palette;
|
2026-10-11 14:03:44 +03:00
|
|
|
final foreground = TextStyle(
|
|
|
|
|
color: palette.foreground,
|
|
|
|
|
fontFamily: widget.fontFamily,
|
2026-10-11 14:24:06 +03:00
|
|
|
fontFamilyFallback: fallbackFor(widget.fontFamily),
|
2026-10-11 14:03:44 +03:00
|
|
|
fontSize: base,
|
|
|
|
|
height: 1.5,
|
|
|
|
|
);
|
2026-10-11 09:33:25 +03:00
|
|
|
|
|
|
|
|
return switch (block) {
|
|
|
|
|
HeadingBlock(:final level, :final inline) => Padding(
|
2026-10-11 22:32:44 +03:00
|
|
|
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,
|
2026-10-11 09:33:25 +03:00
|
|
|
),
|
2026-10-11 22:32:44 +03:00
|
|
|
),
|
|
|
|
|
),
|
2026-10-11 09:33:25 +03:00
|
|
|
ParagraphBlock(:final inline) => Padding(
|
2026-10-11 22:32:44 +03:00
|
|
|
padding: EdgeInsets.only(bottom: widget.ownPage ? base * 0.35 : base),
|
|
|
|
|
child: Text.rich(
|
|
|
|
|
TextSpan(children: _inline(inline)),
|
|
|
|
|
style: foreground,
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-10-11 09:33:25 +03:00
|
|
|
AddressBlock(:final inline) => Padding(
|
2026-10-11 22:32:44 +03:00
|
|
|
padding: EdgeInsets.only(bottom: base),
|
|
|
|
|
child: Text.rich(
|
|
|
|
|
TextSpan(children: _inline(inline)),
|
|
|
|
|
style: foreground.copyWith(fontStyle: FontStyle.italic),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-10-11 09:33:25 +03:00
|
|
|
QuoteBlock(:final children) => Container(
|
2026-10-11 22:32:44 +03:00
|
|
|
margin: EdgeInsets.only(bottom: base),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
border: BorderDirectional(
|
|
|
|
|
start: BorderSide(color: palette.border, width: 3),
|
2026-10-11 09:33:25 +03:00
|
|
|
),
|
2026-10-11 22:32:44 +03:00
|
|
|
),
|
|
|
|
|
padding: EdgeInsetsDirectional.only(start: base),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
children: [
|
|
|
|
|
for (final child in children) _block(context, child, base),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-10-11 09:33:25 +03:00
|
|
|
PreBlock(:final text) => Container(
|
2026-10-11 22:32:44 +03:00
|
|
|
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,
|
2026-10-11 09:33:25 +03:00
|
|
|
),
|
|
|
|
|
),
|
2026-10-11 22:32:44 +03:00
|
|
|
),
|
|
|
|
|
),
|
2026-10-11 09:33:25 +03:00
|
|
|
RuleBlock() => Padding(
|
2026-10-11 22:32:44 +03:00
|
|
|
padding: EdgeInsets.symmetric(
|
|
|
|
|
vertical: widget.ownPage ? base * 0.6 : base * 2,
|
|
|
|
|
),
|
|
|
|
|
child: Divider(color: palette.border, height: 1),
|
|
|
|
|
),
|
2026-10-11 14:03:44 +03:00
|
|
|
// 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(
|
2026-10-11 22:32:44 +03:00
|
|
|
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),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-10-11 09:33:25 +03:00
|
|
|
ListBlock(:final ordered, :final start, :final items) => Padding(
|
2026-10-11 22:32:44 +03:00
|
|
|
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,
|
2026-10-11 09:33:25 +03:00
|
|
|
children: [
|
2026-10-11 22:32:44 +03:00
|
|
|
SizedBox(
|
|
|
|
|
width: base * 1.5,
|
|
|
|
|
child: Text(
|
|
|
|
|
ordered ? '${start + i}.' : '\u2022',
|
|
|
|
|
style: foreground,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
2026-10-11 09:33:25 +03:00
|
|
|
children: [
|
2026-10-11 22:32:44 +03:00
|
|
|
for (final child in items[i].blocks)
|
|
|
|
|
_block(context, child, base),
|
2026-10-11 09:33:25 +03:00
|
|
|
],
|
|
|
|
|
),
|
2026-10-11 22:32:44 +03:00
|
|
|
),
|
2026-10-11 09:33:25 +03:00
|
|
|
],
|
|
|
|
|
),
|
2026-10-11 22:32:44 +03:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-10-11 09:33:25 +03:00
|
|
|
],
|
|
|
|
|
),
|
2026-10-11 22:32:44 +03:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-10-11 09:33:25 +03:00
|
|
|
ImageBlock(:final src, :final alt, :final allowed) => Padding(
|
2026-10-11 22:32:44 +03:00
|
|
|
padding: EdgeInsets.only(bottom: base),
|
|
|
|
|
child: allowed ? _image(src, alt, base) : _imageFallback(alt, base),
|
|
|
|
|
),
|
2026-10-11 09:33:25 +03:00
|
|
|
FormBlock() => Padding(
|
2026-10-11 22:32:44 +03:00
|
|
|
padding: EdgeInsets.only(bottom: base),
|
2026-10-11 22:56:56 +03:00
|
|
|
// 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}'),
|
2026-10-11 22:32:44 +03:00
|
|
|
child: _MewsForm(
|
|
|
|
|
form: block,
|
|
|
|
|
foreground: foreground,
|
|
|
|
|
onFormSubmit: widget.onFormSubmit,
|
2026-10-11 22:56:56 +03:00
|
|
|
onFormChange: widget.onFormChange,
|
2026-10-11 22:32:44 +03:00
|
|
|
ownPage: widget.ownPage,
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-10-11 09:33:25 +03:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:07:25 +03:00
|
|
|
/// 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<Uint8List?>(
|
|
|
|
|
future: _images.putIfAbsent(src, () {
|
|
|
|
|
final arriving = loader(src);
|
|
|
|
|
arriving.then((bytes) {
|
|
|
|
|
if (bytes == null) _images.remove(src);
|
|
|
|
|
});
|
|
|
|
|
return arriving;
|
|
|
|
|
}),
|
2026-10-11 22:32:44 +03:00
|
|
|
builder: (context, snapshot) =>
|
|
|
|
|
snapshot.connectionState != ConnectionState.done
|
2026-10-11 22:07:25 +03:00
|
|
|
? const SizedBox.shrink()
|
|
|
|
|
: snapshot.data == null
|
2026-10-11 22:32:44 +03:00
|
|
|
? _imageFallback(alt, base, unavailable: true)
|
|
|
|
|
: Image.memory(
|
|
|
|
|
snapshot.data!,
|
|
|
|
|
fit: BoxFit.scaleDown,
|
|
|
|
|
errorBuilder: (_, _, _) =>
|
|
|
|
|
_imageFallback(alt, base, unavailable: true),
|
|
|
|
|
),
|
2026-10-11 22:07:25 +03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
Widget _imageFallback(String alt, double base, {bool unavailable = false}) {
|
|
|
|
|
final palette = widget.palette;
|
|
|
|
|
final strings = StringsScope.of(context);
|
2026-10-11 22:32:44 +03:00
|
|
|
final messageId = unavailable
|
|
|
|
|
? 'error-image-unavailable'
|
|
|
|
|
: 'error-image-blocked';
|
|
|
|
|
final message = alt.isEmpty
|
|
|
|
|
? strings.t(messageId)
|
|
|
|
|
: '${strings.t(messageId)} $alt';
|
2026-10-11 09:33:25 +03:00
|
|
|
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.
|
2026-10-11 22:32:44 +03:00
|
|
|
List<InlineSpan> _inline(
|
|
|
|
|
List<MewsInline> inline, [
|
|
|
|
|
TapGestureRecognizer? linkRecognizer,
|
|
|
|
|
]) {
|
2026-10-11 09:33:25 +03:00
|
|
|
final palette = widget.palette;
|
|
|
|
|
return [
|
|
|
|
|
for (final node in inline)
|
|
|
|
|
switch (node) {
|
2026-10-11 22:32:44 +03:00
|
|
|
MewsText(:final text) => TextSpan(
|
|
|
|
|
text: text,
|
|
|
|
|
recognizer: linkRecognizer,
|
|
|
|
|
),
|
2026-10-11 09:33:25 +03:00
|
|
|
MewsLineBreak() => const TextSpan(text: '\n'),
|
|
|
|
|
MewsEmphasis(:final style, :final children) => TextSpan(
|
2026-10-11 22:32:44 +03:00
|
|
|
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,
|
2026-10-11 09:33:25 +03:00
|
|
|
),
|
2026-10-11 22:32:44 +03:00
|
|
|
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),
|
2026-10-11 09:33:25 +03:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:32:44 +03:00
|
|
|
/// 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<MewsInline> 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
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,
|
2026-10-11 22:56:56 +03:00
|
|
|
this.onFormChange,
|
2026-10-11 14:03:44 +03:00
|
|
|
this.ownPage = false,
|
2026-10-11 09:33:25 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final FormBlock form;
|
|
|
|
|
final TextStyle foreground;
|
|
|
|
|
final void Function(FormBlock form, Map<String, String> values) onFormSubmit;
|
2026-10-11 22:56:56 +03:00
|
|
|
final void Function(FormBlock form, Map<String, String> values)? onFormChange;
|
2026-10-11 09:33:25 +03:00
|
|
|
|
2026-10-11 14:03:44 +03:00
|
|
|
/// 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;
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
@override
|
|
|
|
|
State<_MewsForm> createState() => _MewsFormState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _MewsFormState extends State<_MewsForm> {
|
|
|
|
|
final _controllers = <String, TextEditingController>{};
|
|
|
|
|
final _values = <String, String>{};
|
|
|
|
|
|
2026-10-11 22:56:56 +03:00
|
|
|
/// 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<String, String> _modelValues(FormBlock form) => {
|
|
|
|
|
for (final field in form.fields)
|
|
|
|
|
if (field case MewsTextField(:final name, :final value)) name: value,
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
@override
|
|
|
|
|
void dispose() {
|
2026-10-11 22:56:56 +03:00
|
|
|
_typing?.cancel();
|
2026-10-11 09:33:25 +03:00
|
|
|
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;
|
|
|
|
|
|
2026-10-11 14:03:44 +03:00
|
|
|
Widget button(MewsSubmitField field) => FilledButton(
|
2026-10-11 22:32:44 +03:00
|
|
|
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),
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-10-11 14:03:44 +03:00
|
|
|
|
|
|
|
|
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<MewsSubmitField>())
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsetsDirectional.only(start: 8),
|
|
|
|
|
child: button(field),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
return Form(
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
children: [
|
|
|
|
|
for (final field in form.fields) _field(field, style),
|
|
|
|
|
for (final field in form.fields.whereType<MewsSubmitField>())
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.only(top: 8),
|
2026-10-11 14:03:44 +03:00
|
|
|
child: button(field),
|
2026-10-11 09:33:25 +03:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _field(MewsFormField field, TextStyle style) {
|
|
|
|
|
Widget control = switch (field) {
|
2026-10-11 22:32:44 +03:00
|
|
|
MewsTextField(
|
|
|
|
|
:final name,
|
|
|
|
|
:final password,
|
|
|
|
|
:final value,
|
|
|
|
|
:final maxLength,
|
|
|
|
|
) =>
|
|
|
|
|
TextFormField(
|
2026-10-11 09:33:25 +03:00
|
|
|
controller: _controllers.putIfAbsent(
|
2026-10-11 22:32:44 +03:00
|
|
|
name,
|
|
|
|
|
() => TextEditingController(
|
|
|
|
|
text: _values.putIfAbsent(name, () => value),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-10-11 09:33:25 +03:00
|
|
|
obscureText: password,
|
|
|
|
|
maxLength: maxLength,
|
|
|
|
|
style: style,
|
|
|
|
|
decoration: InputDecoration(labelText: field.label, isDense: true),
|
2026-10-11 22:56:56 +03:00
|
|
|
onChanged: (text) => _changed(widget.form, name, text),
|
2026-10-11 09:33:25 +03:00
|
|
|
),
|
2026-10-11 22:32:44 +03:00
|
|
|
MewsTextAreaField(:final name, :final value, :final rows) =>
|
|
|
|
|
TextFormField(
|
2026-10-11 09:33:25 +03:00
|
|
|
controller: _controllers.putIfAbsent(
|
2026-10-11 22:32:44 +03:00
|
|
|
name,
|
|
|
|
|
() => TextEditingController(
|
|
|
|
|
text: _values.putIfAbsent(name, () => value),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-10-11 09:33:25 +03:00
|
|
|
minLines: rows,
|
|
|
|
|
maxLines: rows,
|
|
|
|
|
style: style,
|
|
|
|
|
decoration: InputDecoration(labelText: field.label, isDense: true),
|
|
|
|
|
),
|
|
|
|
|
MewsCheckboxField(:final name, :final checked) => CheckboxListTile(
|
2026-10-11 22:32:44 +03:00
|
|
|
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<String>(
|
2026-10-11 09:33:25 +03:00
|
|
|
groupValue: _values.putIfAbsent(name, () => checked ? value : ''),
|
|
|
|
|
onChanged: (value) => setState(() => _values[name] = value ?? ''),
|
|
|
|
|
child: RadioListTile<String>(
|
|
|
|
|
value: value,
|
2026-10-11 22:32:44 +03:00
|
|
|
title: field.label == null
|
|
|
|
|
? null
|
|
|
|
|
: Text(field.label!, style: style),
|
2026-10-11 09:33:25 +03:00
|
|
|
),
|
|
|
|
|
),
|
2026-10-11 22:32:44 +03:00
|
|
|
MewsSelectField(:final name, :final options) =>
|
|
|
|
|
options.isEmpty
|
|
|
|
|
? const SizedBox.shrink()
|
|
|
|
|
: DropdownButtonFormField<String>(
|
|
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-10-11 09:33:25 +03:00
|
|
|
MewsSubmitField() => const SizedBox.shrink(),
|
|
|
|
|
MewsHiddenField() => const SizedBox.shrink(),
|
|
|
|
|
};
|
|
|
|
|
return control;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _submit(FormBlock form) {
|
2026-10-11 22:56:56 +03:00
|
|
|
// 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<String, String> _valuesOf(FormBlock form) {
|
2026-10-11 09:33:25 +03:00
|
|
|
final values = <String, String>{};
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-11 22:56:56 +03:00
|
|
|
return values;
|
2026-10-11 09:33:25 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|