feat: offer a non-Mews web page as a simplified view or in the browser
This commit is contained in:
parent
cea66fbb42
commit
00e42c925c
24 changed files with 863 additions and 152 deletions
|
|
@ -15,6 +15,7 @@ import 'package:flutter/material.dart';
|
|||
import '../../l10n/strings.dart';
|
||||
import '../../application/fonts.dart';
|
||||
import '../../mews/document.dart';
|
||||
import '../../mews/html.dart' show htmlSpecVersion;
|
||||
import 'palettes.dart';
|
||||
|
||||
/// How long the reader has to stop typing before a search box narrows its
|
||||
|
|
@ -84,6 +85,11 @@ class _MewsPageViewState extends State<MewsPageView> {
|
|||
int _focusedLink = -1;
|
||||
int _linkCount = 0;
|
||||
int _linkCounter = 0;
|
||||
|
||||
/// Forms of the page, counted in reading order as it is built. A web page
|
||||
/// can carry the same form twice — a search box in both a desktop and a
|
||||
/// mobile header — so the action alone does not identify one.
|
||||
int _formCounter = 0;
|
||||
final _linkHrefs = <String>[];
|
||||
final _linkSpans = <TextSpan>[];
|
||||
final _region = FocusNode();
|
||||
|
|
@ -157,8 +163,13 @@ class _MewsPageViewState extends State<MewsPageView> {
|
|||
_linkHrefs.clear();
|
||||
_linkSpans.clear();
|
||||
_linkCounter = 0;
|
||||
_formCounter = 0;
|
||||
final blocks = [
|
||||
if (!widget.page.isMews)
|
||||
// A converted web page says so: what it shows is only the part of it
|
||||
// the Mews subset reaches.
|
||||
if (widget.page.specVersion == htmlSpecVersion)
|
||||
_notice(strings.t('error-simplified-view'), base, palette)
|
||||
else if (!widget.page.isMews)
|
||||
_notice(strings.t('error-non-mews'), base, palette),
|
||||
for (final block in widget.page.body) _block(context, block, base),
|
||||
];
|
||||
|
|
@ -242,6 +253,72 @@ class _MewsPageViewState extends State<MewsPageView> {
|
|||
);
|
||||
}
|
||||
|
||||
/// A table, every row padded out to the width of the widest. Flutter's
|
||||
/// Table requires each row to hold the same number of cells, and a web
|
||||
/// page's rows are routinely ragged. A colspan is not honored, so a cell
|
||||
/// that spans several columns simply sits in the first of them.
|
||||
Widget _table(
|
||||
List<MewsTableRow> rows,
|
||||
String? caption,
|
||||
double base,
|
||||
TextStyle foreground,
|
||||
) {
|
||||
final palette = widget.palette;
|
||||
var width = 0;
|
||||
for (final row in rows) {
|
||||
if (row.cells.length > width) width = row.cells.length;
|
||||
}
|
||||
// Nothing to draw, and a Table needs at least one column.
|
||||
if (width == 0) return const SizedBox.shrink();
|
||||
|
||||
return 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 (var i = 0; i < width; i++)
|
||||
TableCell(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: base * 0.6,
|
||||
vertical: base * 0.3,
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: i < row.cells.length
|
||||
? _inline(row.cells[i].inline)
|
||||
: const <InlineSpan>[],
|
||||
),
|
||||
style: foreground.copyWith(
|
||||
fontWeight:
|
||||
i < row.cells.length && row.cells[i].header
|
||||
? FontWeight.bold
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _notice(String text, double base, MewsPalette palette) => Padding(
|
||||
padding: EdgeInsets.only(bottom: base),
|
||||
child: Text(
|
||||
|
|
@ -417,57 +494,19 @@ class _MewsPageViewState extends State<MewsPageView> {
|
|||
],
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TableBlock(:final rows, :final caption) =>
|
||||
_table(rows, caption, base, foreground),
|
||||
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}'),
|
||||
// Keyed by where it sits and 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. The
|
||||
// position keeps two forms with one action apart.
|
||||
key: ValueKey('form:${_formCounter++}:${block.action}'),
|
||||
child: _MewsForm(
|
||||
form: block,
|
||||
foreground: foreground,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import '../../application/controllers/browser_controller.dart'
|
|||
import '../../database/database.dart' show ClientCertificate;
|
||||
import '../../application/pages/mews_urls.dart';
|
||||
import '../../mews/document.dart';
|
||||
import '../../mews/parser.dart' show mewsPlainText;
|
||||
import '../app.dart';
|
||||
import 'address_bar.dart';
|
||||
import 'mews_page_view.dart';
|
||||
|
|
@ -46,7 +45,11 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
/// scrolls down, back on any upward scroll. A pointer host with a keyboard
|
||||
/// has room to spare and keys to reach the bar, so it never hides.
|
||||
bool _chromeVisible = true;
|
||||
bool _showBrokenText = false;
|
||||
|
||||
/// The address whose simplified view the reader asked for. Keyed by address
|
||||
/// so the choice applies to that page alone: a link from it that also fails
|
||||
/// explains itself rather than silently simplifying too.
|
||||
String? _simplifiedFor;
|
||||
final _checkedSubscriptions = <String>{};
|
||||
|
||||
// The finger behind a possible tap on the page, tracked outside the
|
||||
|
|
@ -274,7 +277,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
return SignalBuilder(builder: (context) {
|
||||
final error = deps.browser.error.value;
|
||||
return switch (error) {
|
||||
BrokenPage(:final source) => _errorPage(context, source),
|
||||
final BrokenPage broken => _errorPage(context, broken),
|
||||
LoadFailed() => _errorPage(context, null),
|
||||
// A protocol status carries the site's own words for what went
|
||||
// wrong.
|
||||
|
|
@ -300,7 +303,15 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
}
|
||||
|
||||
_checkSubscription(context, page);
|
||||
_showBrokenText = false;
|
||||
|
||||
return _pageView(context, page);
|
||||
}
|
||||
|
||||
/// A page as the reader sees it. Both the fetched page and the simplified
|
||||
/// view of a web page come through here, so they share the palette, the
|
||||
/// fonts, the link handling and the choice to keep a quotation.
|
||||
Widget _pageView(BuildContext context, MewsPage page) {
|
||||
final deps = AppScope.of(context).deps;
|
||||
|
||||
return SignalBuilder(builder: (context) {
|
||||
final settings = deps.settings;
|
||||
|
|
@ -536,20 +547,28 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
Widget _errorPage(BuildContext context, String? brokenSource) {
|
||||
/// The page that stands in for one the reader cannot have: [broken] is a
|
||||
/// page the Mews parse refused, and null is a page that would not load.
|
||||
Widget _errorPage(BuildContext context, BrokenPage? broken) {
|
||||
final strings = AppScope.of(context).strings;
|
||||
final deps = AppScope.of(context).deps;
|
||||
|
||||
// A web address can be shown by the system's browser, so a page that
|
||||
// will not load here can leave; a protocol page has nowhere else to go,
|
||||
// so only trying again is offered there.
|
||||
// will not load here, or that this client cannot read, can leave; a
|
||||
// protocol page has nowhere else to go, so only trying again is offered
|
||||
// there.
|
||||
final failed = deps.browser.currentAddress;
|
||||
final failedScheme = Uri.tryParse(failed)?.scheme;
|
||||
final canOpenOutside =
|
||||
brokenSource == null && (failedScheme == 'http' || failedScheme == 'https');
|
||||
final canOpenOutside = failedScheme == 'http' || failedScheme == 'https';
|
||||
|
||||
// The simplified view is a page, not a notice, so it fills the screen
|
||||
// rather than sitting in the centered column below.
|
||||
if (broken?.simplified != null && _simplifiedFor == failed) {
|
||||
return _pageView(context, broken!.simplified!);
|
||||
}
|
||||
|
||||
Widget content;
|
||||
if (brokenSource == null) {
|
||||
if (broken == null) {
|
||||
content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
|
@ -568,25 +587,33 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
],
|
||||
],
|
||||
);
|
||||
} else if (_showBrokenText) {
|
||||
content = Column(
|
||||
children: [
|
||||
Text(strings.t('error-broken-page-text-title'),
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(child: SingleChildScrollView(child: Text(mewsPlainText(brokenSource)))),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// A page claiming the Mews marker that still will not parse has a real
|
||||
// markup error. One that never claimed it is an ordinary web page, and
|
||||
// saying so is the honest answer; the MIME type cannot tell them apart,
|
||||
// since Mews pages are served as text/html (spec 7.1).
|
||||
content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(strings.t('error-broken-page'), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _press(() => setState(() => _showBrokenText = true)),
|
||||
child: Text(strings.t('error-broken-page-show-text')),
|
||||
Text(
|
||||
strings.t(broken.declaresMews
|
||||
? 'error-broken-page'
|
||||
: 'error-not-mews-page'),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (broken.simplified != null)
|
||||
FilledButton(
|
||||
onPressed: _press(() => setState(() => _simplifiedFor = failed)),
|
||||
child: Text(strings.t('error-show-simplified')),
|
||||
),
|
||||
if (canOpenOutside) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: _press(() => _openOutside(failed)),
|
||||
child: Text(strings.t('error-open-in-browser')),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue