import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show ScrollDirection; import 'package:signals/signals_flutter.dart' show SignalBuilder; import '../../application/controllers/browser_controller.dart' show BrokenPage, LoadFailed; 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(); /// The address bar is on screen from the start, hidden while the reader /// scrolls down, back on any upward scroll. bool _chromeVisible = true; bool _showBrokenText = false; final _checkedSubscriptions = {}; @override void initState() { super.initState(); _scroll.addListener(_onScroll); } @override void dispose() { _scroll.removeListener(_onScroll); _scroll.dispose(); super.dispose(); } void _onScroll() { 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; return 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 ? const AddressBar() : 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, ).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), ), ], ), ); }), ), ], ), ); } 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), 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, ); 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), 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, ); 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(); } Widget _errorPage(BuildContext context, String? brokenSource) { final strings = AppScope.of(context).strings; final deps = AppScope.of(context).deps; 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')), ), ], ); } 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); }); } }