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

515 lines
19 KiB
Dart
Raw Normal View History

import 'dart:io' show Process;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, TargetPlatform;
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>{};
/// 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);
}
}
@override
Widget build(BuildContext context) {
final deps = AppScope.of(context).deps;
final reader = Scaffold(
body: 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,
onBack: browser.back,
onForward: browser.forward,
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) => deps.browser.open(url),
onFormSubmit: (form, values) =>
deps.browser.submitForm(form.action, form.method, values),
onFormChange: (form, values) =>
deps.browser.narrowWhileTyping(form.action, values),
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: () => 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: () => 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: () => deps.browser.chooseCertificate(certificate.id),
child: Text(certificate.name),
),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => 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: () => deps.browser.trustPresentedCertificate(),
child: Text(strings.t('error-certificate-trust')),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => 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: () => deps.browser.reload(),
child: Text(strings.t('error-try-again')),
),
if (canOpenOutside) ...[
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => _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: () => 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);
});
}
}