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

290 lines
9.6 KiB
Dart

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 'fob.dart';
import 'mews_page_view.dart';
import 'palettes.dart';
import 'settings_sheet.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<ReaderScreen> createState() => _ReaderScreenState();
}
class _ReaderScreenState extends State<ReaderScreen> {
final _scroll = ScrollController();
/// Address bar and FOB share one visibility: on screen from the start,
/// hidden while the reader scrolls down, back on any upward scroll.
bool _chromeVisible = true;
bool _showBrokenText = false;
final _reportedNewPosts = <String>{};
final _checkedSubscriptions = <String>{};
@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 scope = AppScope.of(context);
final deps = scope.deps;
final strings = scope.strings;
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;
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,
onBack: browser.back,
onForward: browser.forward,
child: Stack(
children: [
Positioned.fill(child: _body(context, page)),
Positioned(
right: 0,
bottom: 0,
child: Fob(
visible: _chromeVisible,
label: strings.t('reader-fob-label'),
onTap: () => showReaderSettingsSheet(context),
),
),
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)));
});
}
}