the-mews/app/lib/presentation/reader/reader_screen.dart
2026-10-13 11:28:06 +03:00

721 lines
28 KiB
Dart

import 'dart:io' show Process;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, TargetPlatform;
import 'package:flutter/gestures.dart'
show kLongPressTimeout, kTouchSlop, PointerDownEvent, PointerUpEvent;
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollDirection;
import 'package:flutter/services.dart' show KeyDownEvent, LogicalKeyboardKey;
import 'package:signals/signals_flutter.dart' show SignalBuilder;
import '../../application/controllers/browser_controller.dart'
show
BrokenPage,
CertificateChanged,
ClientCertificateRequired,
LoadFailed,
ProtocolFailure,
RedirectLoop,
UnsupportedPage;
import '../../database/database.dart' show ClientCertificate;
import '../../application/pages/mews_urls.dart';
import '../../mews/document.dart';
import '../app.dart';
import 'address_bar.dart';
import 'mews_page_view.dart';
import 'palettes.dart';
import 'swipe_navigator.dart';
/// The whole v1 UI: an address bar that hides on scroll, the page reader with
/// swipe back/forward, and error states that never guess.
class ReaderScreen extends StatefulWidget {
const ReaderScreen({super.key});
@override
State<ReaderScreen> createState() => _ReaderScreenState();
}
class _ReaderScreenState extends State<ReaderScreen> {
final _scroll = ScrollController();
final _addressFocus = FocusNode();
final _pageFocus = FocusNode();
/// Around the error pages, which have no page view to take the focus; and
/// the error whose page last had its first control focused.
final _errorFocus = FocusNode(canRequestFocus: false, skipTraversal: true);
Object? _focusedError;
/// The address bar is on screen from the start, hidden while the reader
/// 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;
/// 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
// gesture arena because taps over selectable text lose it.
Offset? _tapDownPosition;
Duration _tapDownTime = Duration.zero;
/// Whether the tap in flight acted on the page: opened a link, submitted
/// a form or pressed a control in one of the chrome's own pages. Such a
/// tap leaves the bar where it was.
bool _tapActed = false;
/// Touch devices navigate with gestures; the others are pointer hosts
/// whose readers reach the chrome with the mouse and the keyboard.
static bool get _isTouch => switch (defaultTargetPlatform) {
TargetPlatform.android || TargetPlatform.iOS => true,
_ => false,
};
@override
void initState() {
super.initState();
_scroll.addListener(_onScroll);
}
@override
void dispose() {
_scroll.removeListener(_onScroll);
_scroll.dispose();
_addressFocus.dispose();
_pageFocus.dispose();
_errorFocus.dispose();
super.dispose();
}
void _onScroll() {
// On a pointer host the bar stays on screen, so scrolling changes
// nothing about it.
if (!_isTouch) return;
if (!_scroll.hasClients) return;
final position = _scroll.position;
// The bar hides when the reader scrolls down and comes back on any
// upward scroll; it is always visible at the top of the page.
final wantVisible = position.pixels <= 0 ||
position.userScrollDirection == ScrollDirection.forward;
if (wantVisible != _chromeVisible) {
setState(() => _chromeVisible = wantVisible);
}
}
void _onPageTapDown(PointerDownEvent event) {
_tapDownPosition = event.position;
_tapDownTime = event.timeStamp;
_tapActed = false;
}
/// A short tap on the page shows a hidden address bar and hides a shown
/// one. A page that fits the viewport never scrolls, so a hidden bar has
/// nothing else to bring it back.
void _onPageTapUp(PointerUpEvent event) {
if (_tapDownPosition == null || !_isTouch) return;
final travel = (event.position - _tapDownPosition!).distance;
_tapDownPosition = null;
// A finger that travelled was scrolling or swiping; one that held that
// long began a selection. Both leave the bar where it was.
if (travel > kTouchSlop) return;
if (event.timeStamp - _tapDownTime >= kLongPressTimeout) return;
// A tap that acts on the page — a link, a form — runs as the gesture
// arena settles after this event, so the decision waits for the frame
// that carries it.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
// A tap that opened something, or that put the focus in a field of
// the page, is not a tap on the reading surface.
if (_tapActed) return;
if (_focusInPageField()) return;
setState(() => _chromeVisible = !_chromeVisible);
});
}
/// Whether a page field, such as a search box, holds the focus: a tap
/// that gave it the focus is a tap into a form, not on the page.
bool _focusInPageField() => FocusManager.instance.primaryFocus?.context
?.findAncestorWidgetOfExactType<TextField>() !=
null;
/// Runs [action] for a control in one of the chrome's own error pages;
/// pressing it acts on the page, so the bar keeps its place.
VoidCallback _press(VoidCallback action) => () {
_tapActed = true;
action();
};
/// The D-pad keys the page leaves to the reader. On a touch host the Menu
/// key shows the chrome with the focus in the address field, and hides it
/// with the focus back on the page. The address field keeps the arrows for
/// its cursor, so Down returns to the page, and Left or Right at the edge
/// of the text moves on to the bar's buttons.
KeyEventResult _onReaderKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final key = event.logicalKey;
if (key == LogicalKeyboardKey.contextMenu && _isTouch) {
final show = !_chromeVisible;
setState(() => _chromeVisible = show);
if (show) {
// The field is built with the chrome, a frame from now.
WidgetsBinding.instance
.addPostFrameCallback((_) => _addressFocus.requestFocus());
} else {
_pageFocus.requestFocus();
}
return KeyEventResult.handled;
}
if (!_addressFocus.hasPrimaryFocus) return KeyEventResult.ignored;
if (key == LogicalKeyboardKey.arrowDown) {
_pageFocus.requestFocus();
return KeyEventResult.handled;
}
final direction = switch (key) {
LogicalKeyboardKey.arrowLeft => TraversalDirection.left,
LogicalKeyboardKey.arrowRight => TraversalDirection.right,
_ => null,
};
if (direction == null) return KeyEventResult.ignored;
final text = (_addressFocus.context!
.findAncestorWidgetOfExactType<EditableText>())!
.controller
.value;
final atEdge = text.selection.isCollapsed &&
text.selection.baseOffset ==
(direction == TraversalDirection.right ? text.text.length : 0);
if (!atEdge) return KeyEventResult.ignored;
_addressFocus.focusInDirection(direction);
return KeyEventResult.handled;
}
@override
Widget build(BuildContext context) {
final deps = AppScope.of(context).deps;
final reader = Scaffold(
// A phone draws under the status bar, so the chrome starts below it;
// the reader content above and beside it already reaches the edges.
body: SafeArea(
top: true,
bottom: false,
left: false,
right: false,
child: Column(
children: [
AnimatedSlide(
offset: _chromeVisible ? Offset.zero : const Offset(0, -1),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
alignment: Alignment.topCenter,
child: _chromeVisible
? AddressBar(focusNode: _addressFocus)
: const SizedBox(width: double.infinity),
),
),
Expanded(
child: SignalBuilder(builder: (context) {
final browser = deps.browser;
final page = browser.currentPage.value;
// The swipe peek uses the current page's palette when there is
// one, so the neighbor slides in on matching paper.
final peekBackground = page == null
? Theme.of(context).scaffoldBackgroundColor
: paletteFor(
pageVariant: page.variant,
readerVariant: deps.settings.variant.value,
dark: Theme.of(context).brightness == Brightness.dark,
url: page.url,
).background;
// The 40em column plus its padding: swipes are accepted
// anywhere in the margin beside it.
final contentWidth =
42 * 17.0 * deps.settings.fontScale.value;
return SwipeNavigator(
canGoBack: browser.canGoBack,
canGoForward: browser.canGoForward,
backView: _neighborView(browser.backPage.value),
forwardView: _neighborView(browser.forwardPage.value),
backTitle: browser.backPage.value?.title,
forwardTitle: browser.forwardPage.value?.title,
background: peekBackground,
contentWidth: page == null ? null : contentWidth,
// A touch host has no margin beside the column, so there
// the page itself carries the back and forward swipes.
swipesAnywhere: _isTouch,
onBack: browser.back,
onForward: browser.forward,
// Opaque, so a tap on a blank stretch of the page counts
// too; the widgets inside keep their own taps.
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: _onPageTapDown,
onPointerUp: _onPageTapUp,
child: Stack(
children: [
Positioned.fill(child: _body(context, page)),
if (browser.loading.value)
const Positioned(
top: 8,
left: 0,
right: 0,
child: LinearProgressIndicator(minHeight: 2),
),
],
),
),
);
}),
),
],
),
),
);
// A pointer host drives the reader from the keyboard as well: the bar
// takes an address (Ctrl or Cmd+L, which selects what is in it), the page
// reloads (Ctrl or Cmd+R), and the walk goes back and forward
// (Alt+Left/Right, or Cmd+Left/Right and Cmd+[ and Cmd+] on a Mac).
// Touch hosts navigate with gestures, or with a D-pad, instead.
//
// Key events reach only the focused branch, and a session starts with
// nothing focused; this node gives them a place to arrive until the page
// takes the focus. Tapping the address field or a control moves the
// focus deeper in, where the shortcuts still apply.
final keyed = Focus(
autofocus: true,
skipTraversal: true,
onKeyEvent: _onReaderKey,
child: reader,
);
if (_isTouch) return keyed;
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.keyL, control: true):
_addressFocus.requestFocus,
const SingleActivator(LogicalKeyboardKey.keyL, meta: true):
_addressFocus.requestFocus,
const SingleActivator(LogicalKeyboardKey.keyR, control: true):
deps.browser.reload,
const SingleActivator(LogicalKeyboardKey.keyR, meta: true):
deps.browser.reload,
const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true):
deps.browser.back,
const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true):
deps.browser.forward,
// The same walk, with the keys a Mac reader reaches for.
const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true):
deps.browser.back,
const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true):
deps.browser.forward,
const SingleActivator(LogicalKeyboardKey.bracketLeft, meta: true):
deps.browser.back,
const SingleActivator(LogicalKeyboardKey.bracketRight, meta: true):
deps.browser.forward,
},
child: keyed,
);
}
Widget _body(BuildContext context, MewsPage? page) {
final deps = AppScope.of(context).deps;
if (page == null) {
return SignalBuilder(builder: (context) {
final error = deps.browser.error.value;
_focusErrorPage(error);
final body = switch (error) {
final BrokenPage broken => _errorPage(context, broken),
LoadFailed() => _errorPage(context, null),
// A protocol status carries the site's own words for what went
// wrong.
ProtocolFailure(:final message) =>
_plainErrorPage(context, 'error-protocol-failed', detail: message),
// A certificate request is a choice, not a failure: the reader
// presents one of their identities, or none. A refused or invalid
// certificate is a failure the reader cannot fix by choosing again.
ClientCertificateRequired(:final status, :final meta) => status == 60
? _identityPickerPage(context, meta)
: _plainErrorPage(
context,
status == 61
? 'error-certificate-not-authorized'
: 'error-certificate-invalid',
detail: meta),
RedirectLoop() => _plainErrorPage(context, 'error-redirect-loop'),
UnsupportedPage() => _plainErrorPage(context, 'error-unsupported-type'),
CertificateChanged() => _certificateErrorPage(context),
null => _emptyPage(context),
};
return Focus(focusNode: _errorFocus, child: body);
});
}
_focusedError = null;
_checkSubscription(context, page);
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;
final font = settings.font.value;
final fontScale = settings.fontScale.value;
final variant = settings.variant.value;
final visited = deps.browser.visitedUrls.value;
final palette = paletteFor(
pageVariant: page.variant,
readerVariant: variant,
dark: Theme.of(context).brightness == Brightness.dark,
url: page.url,
);
return MewsPageView(
// Our own pages carry their view state in the address, so the key
// leaves the query out: narrowing a list as the reader types rebuilds
// the page without taking the search box out from under them.
key: ValueKey(_viewKey(page.url)),
page: page,
ownPage: MewsUrls.isInternal(page.url),
palette: palette,
fontFamily: font,
fontScale: fontScale,
visitedUrls: visited,
onLinkTap: (url) {
_tapActed = true;
deps.browser.open(url);
},
onFormSubmit: (form, values) {
_tapActed = true;
deps.browser.submitForm(form.action, form.method, values);
},
onFormChange: (form, values) =>
deps.browser.narrowWhileTyping(form.action, values),
// The reader's marked text is kept with the page it came from, so
// its citation can name the author when the page did.
onSaveQuotation: (text) async {
// Taken before the wait, so the note does not reach across it.
final messenger = ScaffoldMessenger.of(context);
final saved = AppScope.of(context).strings.t('quotations-saved');
await deps.db.addQuotation(
quote: text,
pageUrl: page.url,
pageTitle: page.title,
author: page.author,
);
if (!mounted) return;
// Closing the toolbar looks the same either way, so the save
// says it happened.
messenger.showSnackBar(SnackBar(
duration: const Duration(seconds: 2),
content: Text(saved),
));
},
fetchImageBytes: deps.browser.fetchImageBytes,
scrollController: _scroll,
focusNode: _pageFocus,
autofocus: true,
onBack: deps.browser.back,
onForward: deps.browser.forward,
onHub: () => deps.browser.open(MewsUrls.hub),
);
});
}
/// What identifies the open page to the widget tree. One of our own pages
/// is identified by its path alone, so a change to its query string, such
/// as a search being narrowed, rebuilds the page in place instead of
/// building a new one.
String _viewKey(String url) {
if (!MewsUrls.isInternal(url)) return url;
return Uri.parse(url).path;
}
/// Renders a neighboring page for the swipe peek, non-interactive: it is a
/// look ahead, and committing the navigation brings the real thing.
Widget? _neighborView(MewsPage? page) {
if (page == null) return null;
final deps = AppScope.of(context).deps;
final settings = deps.settings;
final palette = paletteFor(
pageVariant: page.variant,
readerVariant: settings.variant.value,
dark: Theme.of(context).brightness == Brightness.dark,
url: page.url,
);
return ColoredBox(
color: palette.background,
child: MewsPageView(
page: page,
ownPage: MewsUrls.isInternal(page.url),
palette: palette,
fontFamily: settings.font.value,
fontScale: settings.fontScale.value,
visitedUrls: deps.browser.visitedUrls.value,
onLinkTap: (_) {},
onFormSubmit: (_, _) {},
),
);
}
Widget _emptyPage(BuildContext context) {
// No page yet: the address bar is the whole UI.
return const SizedBox.expand();
}
/// An error page that says [messageId], optionally the site's own words
/// under it, and offers to try again.
Widget _plainErrorPage(BuildContext context, String messageId, {String? detail}) {
final strings = AppScope.of(context).strings;
final deps = AppScope.of(context).deps;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(strings.t(messageId), textAlign: TextAlign.center),
if (detail != null && detail.isNotEmpty) ...[
const SizedBox(height: 8),
Text(detail, textAlign: TextAlign.center),
],
const SizedBox(height: 16),
FilledButton(
onPressed: _press(() => deps.browser.reload()),
child: Text(strings.t('error-try-again')),
),
],
),
),
);
}
/// The choice a certificate request asks for: one of the reader's
/// identities, presented from now on to the site that asked.
Widget _identityPickerPage(BuildContext context, String meta) {
final strings = AppScope.of(context).strings;
final deps = AppScope.of(context).deps;
return FutureBuilder<List<ClientCertificate>>(
future: deps.db.allClientCertificates(),
builder: (context, snapshot) {
final held = snapshot.data ?? const <ClientCertificate>[];
return Center(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(strings.t('certificates-choose'), textAlign: TextAlign.center),
if (meta.isNotEmpty) ...[
const SizedBox(height: 8),
Text(meta, textAlign: TextAlign.center),
],
const SizedBox(height: 16),
if (held.isEmpty)
FilledButton(
onPressed: _press(() => deps.browser.open(MewsUrls.certificates)),
child: Text(strings.t('certificates-add')),
)
else
for (final certificate in held)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: FilledButton.tonal(
onPressed: _press(() => deps.browser.chooseCertificate(certificate.id)),
child: Text(certificate.name),
),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: _press(() => deps.browser.reload()),
child: Text(strings.t('error-try-again')),
),
],
),
),
),
);
},
);
}
/// A changed certificate is the reader's choice to make or refuse, so the
/// error page carries both: trust what was presented, or try again.
Widget _certificateErrorPage(BuildContext context) {
final strings = AppScope.of(context).strings;
final deps = AppScope.of(context).deps;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(strings.t('error-certificate-changed'), textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: _press(() => deps.browser.trustPresentedCertificate()),
child: Text(strings.t('error-certificate-trust')),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: _press(() => deps.browser.reload()),
child: Text(strings.t('error-try-again')),
),
],
),
),
);
}
/// Hands [url] to the system's web browser. The reader does not watch
/// what happens to it there, and a system with no browser answers nothing
/// rather than failing here.
///
/// The launcher asks GIO on Linux, which can fail to see the session's
/// browser from the environment the app runs in, and a binary built before
/// the launcher was added answers not at all; in both cases xdg-open asks
/// the session directly.
Future<void> _openOutside(String url) async {
final uri = Uri.tryParse(url);
if (uri == null) return;
var opened = false;
try {
opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
} on Exception {
// No answer; the fallback below asks the session itself.
}
if (opened) return;
if (defaultTargetPlatform == TargetPlatform.linux) {
final result = await Process.run('xdg-open', [url]);
// A missing xdg-open or a session without a browser answers with an
// error; nothing more to do but stay where we are.
if (result.exitCode != 0) return;
}
}
/// 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, 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 = 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 (broken == null) {
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(strings.t('error-load-failed'), textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: _press(() => deps.browser.reload()),
child: Text(strings.t('error-try-again')),
),
if (canOpenOutside) ...[
const SizedBox(height: 8),
OutlinedButton(
onPressed: _press(() => _openOutside(failed)),
child: Text(strings.t('error-open-in-browser')),
),
],
],
);
} 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(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')),
),
],
],
);
}
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: content,
),
);
}
/// Puts the focus on the first control of an error page as it appears, so a
/// D-pad has somewhere to start. A reader typing in the address field keeps
/// the focus there.
void _focusErrorPage(Object? error) {
if (identical(error, _focusedError)) return;
_focusedError = error;
if (error == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _focusInPageField()) return;
_errorFocus.traversalDescendants.firstOrNull?.requestFocus();
});
}
/// Records the posts of a subscribed page once per page load, so what is new
/// shows up on the subscriptions and posts pages (spec 6.1). The controller
/// no-ops for a page the reader is not subscribed to.
void _checkSubscription(BuildContext context, MewsPage page) {
if (_checkedSubscriptions.contains(page.url)) return;
_checkedSubscriptions.add(page.url);
final deps = AppScope.of(context).deps;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) deps.subscriptions.refreshSite(page);
});
}
}