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 '../../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, a settings FOB that appears on scroll, 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 _reportedNewPosts = {}; 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 and FOB hide when the reader scrolls down and come back on any // upward scroll; both are 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; _maybeReportNewPosts(context, page); final palette = paletteFor( pageVariant: page.variant, readerVariant: variant, dark: Theme.of(context).brightness == Brightness.dark, ); return MewsPageView( key: ValueKey(page.url), page: page, 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, 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, ), ); } /// Revalidates a subscribed page once per page load so new entries surface /// (spec 6.1). The library controller no-ops for unsubscribed pages. 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.library.refreshSubscription(page); }); } /// Shows the new-posts snackbar once per site after the refresh reports /// entries the reader has not seen. void _maybeReportNewPosts(BuildContext context, MewsPage page) { final scope = AppScope.of(context); final fresh = scope.deps.library.newEntries.value[page.url]; if (fresh == null || fresh.isEmpty || _reportedNewPosts.contains(page.url)) return; _reportedNewPosts.add(page.url); final strings = scope.strings; final message = fresh.length == 1 ? strings.t('new-posts-one') : strings.t('new-posts-many', {'count': fresh.length}); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); }); } }