263 lines
8.7 KiB
Dart
263 lines
8.7 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,
|
|
backTitle: browser.backTitle.value,
|
|
forwardTitle: browser.forwardTitle.value,
|
|
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,
|
|
);
|
|
});
|
|
}
|
|
|
|
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)));
|
|
});
|
|
}
|
|
}
|