the-mews/app/lib/presentation/reader/reader_screen.dart

601 lines
23 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 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 '../../mews/parser.dart' show mewsPlainText;
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();
/// 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;
bool _showBrokenText = false;
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();
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();
};
@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 instead.
if (_isTouch) return reader;
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,
},
// Key events reach only the focused branch, and a session starts with
// nothing focused; this node gives them a place to arrive. Tapping the
// address field or a control moves the focus deeper in, where the
// shortcuts still apply.
child: Focus(autofocus: true, skipTraversal: true, child: reader),
);
}
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;
return switch (error) {
BrokenPage(:final source) => _errorPage(context, source),
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),
};
});
}
_checkSubscription(context, page);
_showBrokenText = false;
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) => deps.db.addQuotation(
quote: text,
pageUrl: page.url,
pageTitle: page.title,
author: page.author,
),
fetchImageBytes: deps.browser.fetchImageBytes,
scrollController: _scroll,
);
});
}
/// 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;
}
}
Widget _errorPage(BuildContext context, String? brokenSource) {
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.
final failed = deps.browser.currentAddress;
final failedScheme = Uri.tryParse(failed)?.scheme;
final canOpenOutside =
brokenSource == null && (failedScheme == 'http' || failedScheme == 'https');
Widget content;
if (brokenSource == 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 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 {
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')),
),
],
);
}
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: content,
),
);
}
/// 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);
});
}
}