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 createState() => _ReaderScreenState(); } class _ReaderScreenState extends State { 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 = {}; /// 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( key: ValueKey(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), fetchImageBytes: deps.browser.fetchImageBytes, scrollController: _scroll, ); }); } /// 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>( 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: () => 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 _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); }); } }