feat: build v1 Mews client: reader UI, gestures, Drift store, fetch actor
This commit is contained in:
parent
d5a0fcbf48
commit
01a36e8aee
56 changed files with 7287 additions and 2 deletions
261
app/lib/presentation/reader/reader_screen.dart
Normal file
261
app/lib/presentation/reader/reader_screen.dart
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
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';
|
||||
|
||||
/// 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();
|
||||
bool _barVisible = true;
|
||||
bool _fobVisible = false;
|
||||
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 hides when the reader scrolls down and comes back on any
|
||||
// upward scroll; it is always visible at the top of the page.
|
||||
final wantBar = position.pixels <= 0 ||
|
||||
position.userScrollDirection == ScrollDirection.forward;
|
||||
// The FOB appears once the page is scrolled, and stays reachable on pages
|
||||
// that fit without scrolling.
|
||||
final contentScrolls = position.maxScrollExtent > 0;
|
||||
final wantFob = contentScrolls ? position.pixels > 16 : true;
|
||||
|
||||
if (wantBar != _barVisible || wantFob != _fobVisible) {
|
||||
setState(() {
|
||||
_barVisible = wantBar;
|
||||
_fobVisible = wantFob;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@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: _barVisible ? 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: _barVisible
|
||||
? const AddressBar()
|
||||
: const SizedBox(width: double.infinity),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SignalBuilder(builder: (context) {
|
||||
final browser = deps.browser;
|
||||
final page = browser.currentPage.value;
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
onHorizontalDragEnd: (details) {
|
||||
final velocity = details.primaryVelocity ?? 0;
|
||||
// Swipe right goes back, swipe left goes forward.
|
||||
if (velocity > 300) {
|
||||
browser.back();
|
||||
} else if (velocity < -300) {
|
||||
browser.forward();
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: _body(context, page)),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Fob(
|
||||
visible: _fobVisible,
|
||||
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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue