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 createState() => _ReaderScreenState(); } class _ReaderScreenState extends State { 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 = {}; // 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() != 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())! .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>( future: deps.db.allClientCertificates(), builder: (context, snapshot) { final held = snapshot.data ?? const []; 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 _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); }); } }