feat: build v1 Mews client: reader UI, gestures, Drift store, fetch actor

This commit is contained in:
randogoth 2026-10-11 09:33:25 +03:00
parent d5a0fcbf48
commit 01a36e8aee
56 changed files with 7287 additions and 2 deletions

View file

@ -0,0 +1,100 @@
import 'package:actors/actors.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart' show SignalBuilder;
import '../application/actors/fetch_actor.dart';
import '../application/controllers/browser_controller.dart';
import '../application/controllers/reader_settings_controller.dart';
import '../database/database.dart';
import '../l10n/strings.dart';
import 'reader/reader_screen.dart';
/// Wires the app's dependencies together and provides them to the tree.
/// Everything is local-first: the database is created up front and the fetch
/// actor is the only path to the network.
class AppDependencies {
AppDependencies({MewsDatabase? database})
: db = database ?? MewsDatabase(),
fetch = Actor.create(FetchHandler.new) {
browser = BrowserController(db: db, fetch: fetch);
settings = ReaderSettingsController(db);
library = LibraryController(db);
}
final MewsDatabase db;
final Actor<FetchCommand, FetchResult> fetch;
late final BrowserController browser;
late final ReaderSettingsController settings;
late final LibraryController library;
Future<void> start() async {
await settings.load();
await library.load();
await browser.restoreLastSession();
}
}
class AppScope extends InheritedWidget {
const AppScope({
required this.deps,
required this.strings,
required super.child,
super.key,
});
final AppDependencies deps;
final Strings strings;
static AppScope of(BuildContext context) =>
context.getInheritedWidgetOfExactType<AppScope>()!;
@override
bool updateShouldNotify(AppScope oldWidget) => false;
}
class MewsApp extends StatelessWidget {
const MewsApp({required this.deps, required this.strings, super.key});
final AppDependencies deps;
final Strings strings;
@override
Widget build(BuildContext context) {
return AppScope(
deps: deps,
strings: strings,
child: const _ThemedApp(),
);
}
}
class _ThemedApp extends StatelessWidget {
const _ThemedApp();
@override
Widget build(BuildContext context) {
final deps = AppScope.of(context).deps;
return SignalBuilder(builder: (context) {
final mode = switch (deps.settings.themeMode.value) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system,
};
return MaterialApp(
title: 'Mews',
themeMode: mode,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff1a4f9c)),
),
darkTheme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xff1a4f9c), brightness: Brightness.dark),
),
home: const ReaderScreen(),
);
});
}
}

View file

@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart' show SignalBuilder;
import '../app.dart';
/// The single-field address bar at the top of the reader. Submitting navigates;
/// the field shows the current page's URL whenever the reader is not typing.
class AddressBar extends StatefulWidget {
const AddressBar({super.key});
@override
State<AddressBar> createState() => _AddressBarState();
}
class _AddressBarState extends State<AddressBar> {
final _controller = TextEditingController();
final _focus = FocusNode();
bool _focused = false;
@override
void initState() {
super.initState();
_focus.addListener(() {
if (_focus.hasFocus != _focused) {
setState(() => _focused = _focus.hasFocus);
if (_focused) {
_controller.selection =
TextSelection(baseOffset: 0, extentOffset: _controller.text.length);
}
}
});
}
@override
void dispose() {
_focus.dispose();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final deps = AppScope.of(context).deps;
final strings = AppScope.of(context).strings;
return SignalBuilder(builder: (context) {
final url = deps.browser.currentPage.value?.url;
if (url != null && !_focused) {
_controller.text = url;
}
return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: TextField(
controller: _controller,
focusNode: _focus,
autofocus: false,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.go,
autocorrect: false,
enableSuggestions: false,
onSubmitted: (value) {
_focus.unfocus();
deps.browser.open(value);
},
decoration: InputDecoration(
hintText: strings.t('reader-address-placeholder'),
labelText: strings.t('reader-address-label'),
prefixIcon: IconButton(
icon: const Icon(Icons.refresh),
tooltip: strings.t('reader-reload'),
onPressed: () => deps.browser.reload(),
),
isDense: true,
border: const OutlineInputBorder(),
),
),
);
});
}
}

View file

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
/// The floating control that appears when the reader scrolls, opening the
/// reader settings bottom sheet.
class Fob extends StatelessWidget {
const Fob({required this.visible, required this.label, required this.onTap, super.key});
final bool visible;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return AnimatedOpacity(
opacity: visible ? 1 : 0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
child: IgnorePointer(
ignoring: !visible,
child: Padding(
padding: const EdgeInsets.all(16),
child: Semantics(
label: label,
button: true,
child: FloatingActionButton(
onPressed: onTap,
child: const Icon(Icons.tune),
),
),
),
),
);
}
}

View file

@ -0,0 +1,482 @@
import 'dart:ui' as ui show TextDirection;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../l10n/strings.dart';
import '../../mews/document.dart';
import 'palettes.dart';
/// Renders a parsed [MewsPage] with the client's own presentation, following
/// the measurements of the default stylesheet mews-0.1.css: a 40em text
/// column, 1.5 line height, and the block margins authors expect.
class MewsPageView extends StatefulWidget {
const MewsPageView({
required this.page,
required this.palette,
required this.fontFamily,
required this.fontScale,
required this.visitedUrls,
required this.onLinkTap,
required this.onFormSubmit,
this.scrollController,
super.key,
});
final MewsPage page;
final MewsPalette palette;
final String fontFamily;
final double fontScale;
final Set<String> visitedUrls;
final void Function(String url) onLinkTap;
final void Function(FormBlock form, Map<String, String> values) onFormSubmit;
final ScrollController? scrollController;
@override
State<MewsPageView> createState() => _MewsPageViewState();
}
class _MewsPageViewState extends State<MewsPageView> {
final _recognizers = <MewsLink, TapGestureRecognizer>{};
@override
void didUpdateWidget(MewsPageView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.page != widget.page) {
_disposeRecognizers();
}
}
@override
void dispose() {
_disposeRecognizers();
super.dispose();
}
void _disposeRecognizers() {
for (final recognizer in _recognizers.values) {
recognizer.dispose();
}
_recognizers.clear();
}
@override
Widget build(BuildContext context) {
final strings = StringsScope.of(context);
final base = 17.0 * widget.fontScale; // body font-size: 106%
final palette = widget.palette;
final blocks = [
if (!widget.page.isMews)
_notice(strings.t('error-non-mews'), base, palette),
for (final block in widget.page.body) _block(context, block, base),
];
return Directionality(
textDirection: widget.page.direction == TextDirection.rtl
? ui.TextDirection.rtl
: ui.TextDirection.ltr,
child: ColoredBox(
color: palette.background,
child: ListView(
controller: widget.scrollController,
padding: EdgeInsets.all(base),
children: [
Center(
child: ConstrainedBox(
// max-width: 40em
constraints: BoxConstraints(maxWidth: 40 * base),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: blocks,
),
),
),
],
),
),
);
}
Widget _notice(String text, double base, MewsPalette palette) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Text(
text,
style: TextStyle(color: palette.foreground.withValues(alpha: 0.7), fontSize: base * 0.85),
),
);
// Blocks -------------------------------------------------------------------
Widget _block(BuildContext context, MewsBlock block, double base) {
final palette = widget.palette;
final foreground = TextStyle(color: palette.foreground, fontSize: base, height: 1.5);
return switch (block) {
HeadingBlock(:final level, :final inline) => Padding(
padding: EdgeInsets.only(
top: level == 1 ? base * 0.5 : base * 1.6,
bottom: base * 0.5,
),
child: Text.rich(
TextSpan(children: _inline(inline)),
style: foreground.copyWith(
fontWeight: FontWeight.bold,
height: 1.25,
fontSize: base * const [1.6, 1.3, 1.1, 1.0, 1.0, 1.0][level - 1],
),
),
),
ParagraphBlock(:final inline) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Text.rich(TextSpan(children: _inline(inline)), style: foreground),
),
AddressBlock(:final inline) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Text.rich(
TextSpan(children: _inline(inline)),
style: foreground.copyWith(fontStyle: FontStyle.italic),
),
),
QuoteBlock(:final children) => Container(
margin: EdgeInsets.only(bottom: base),
decoration: BoxDecoration(
border: BorderDirectional(start: BorderSide(color: palette.border, width: 3)),
),
padding: EdgeInsetsDirectional.only(start: base),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [for (final child in children) _block(context, child, base)],
),
),
PreBlock(:final text) => Container(
margin: EdgeInsets.only(bottom: base),
color: palette.preBackground,
padding: EdgeInsets.all(base * 0.75),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(
text,
style: foreground.copyWith(
fontFamily: 'monospace',
fontSize: base * 0.9,
),
),
),
),
RuleBlock() => Padding(
padding: EdgeInsets.symmetric(vertical: base * 2),
child: Divider(color: palette.border, height: 1),
),
ListBlock(:final ordered, :final start, :final items) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < items.length; i++)
Padding(
padding: EdgeInsets.only(bottom: base * 0.25),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: base * 1.5,
child: Text(
ordered ? '${start + i}.' : '\u2022',
style: foreground,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final child in items[i].blocks) _block(context, child, base),
],
),
),
],
),
),
],
),
),
DefinitionListBlock(:final items) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final item in items) ...[
Text.rich(
TextSpan(children: _inline(item.term)),
style: foreground.copyWith(fontWeight: FontWeight.bold),
),
Padding(
padding: EdgeInsetsDirectional.only(start: base * 1.5, bottom: base * 0.5),
child: Text.rich(TextSpan(children: _inline(item.definition)), style: foreground),
),
],
],
),
),
TableBlock(:final rows, :final caption) => Padding(
padding: EdgeInsets.only(bottom: base),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (caption != null)
Text(caption,
style: foreground.copyWith(fontWeight: FontWeight.bold)),
Table(
border: TableBorder.all(color: palette.border),
defaultVerticalAlignment: TableCellVerticalAlignment.top,
children: [
for (final row in rows)
TableRow(
children: [
for (final cell in row.cells)
TableCell(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: base * 0.6,
vertical: base * 0.3,
),
child: Text.rich(
TextSpan(children: _inline(cell.inline)),
style: foreground.copyWith(
fontWeight: cell.header ? FontWeight.bold : null,
),
),
),
),
],
),
],
),
],
),
),
ImageBlock(:final src, :final alt, :final allowed) => Padding(
padding: EdgeInsets.only(bottom: base),
child: allowed
? Image.network(
src,
fit: BoxFit.scaleDown,
errorBuilder: (_, _, _) => _imageFallback(alt, base, unavailable: true),
)
: _imageFallback(alt, base),
),
FormBlock() => Padding(
padding: EdgeInsets.only(bottom: base),
child: _MewsForm(
form: block,
foreground: foreground,
onFormSubmit: widget.onFormSubmit,
),
),
};
}
Widget _imageFallback(String alt, double base, {bool unavailable = false}) {
final palette = widget.palette;
final strings = StringsScope.of(context);
final messageId = unavailable ? 'error-image-unavailable' : 'error-image-blocked';
final message = alt.isEmpty ? strings.t(messageId) : '${strings.t(messageId)} $alt';
return Container(
width: double.infinity,
padding: EdgeInsets.all(base * 0.75),
decoration: BoxDecoration(
border: Border.all(color: palette.border),
color: palette.preBackground,
),
child: Text(
message,
style: TextStyle(color: palette.foreground, fontSize: base * 0.9),
),
);
}
/// Builds the span tree. [linkRecognizer] carries the enclosing link's
/// recognizer down to every glyph-owning span: hit-testing resolves to leaf
/// spans only, so a recognizer on a parent span alone never fires.
List<InlineSpan> _inline(List<MewsInline> inline, [TapGestureRecognizer? linkRecognizer]) {
final palette = widget.palette;
return [
for (final node in inline)
switch (node) {
MewsText(:final text) => TextSpan(text: text, recognizer: linkRecognizer),
MewsLineBreak() => const TextSpan(text: '\n'),
MewsEmphasis(:final style, :final children) => TextSpan(
style: switch (style) {
EmphasisStyle.strong => const TextStyle(fontWeight: FontWeight.bold),
EmphasisStyle.emphasis => const TextStyle(fontStyle: FontStyle.italic),
EmphasisStyle.term => const TextStyle(fontStyle: FontStyle.italic),
EmphasisStyle.quote => const TextStyle(fontStyle: FontStyle.italic),
_ => const TextStyle(fontFamily: 'monospace'),
},
children: _inline(children, linkRecognizer)),
MewsLink(:final href, :final children) => TextSpan(
style: TextStyle(
color: widget.visitedUrls.contains(href) ? palette.visited : palette.link,
decoration: TextDecoration.underline,
),
recognizer: _recognizer(node),
children: _inline(children, _recognizer(node))),
},
];
}
TapGestureRecognizer _recognizer(MewsLink link) {
return _recognizers.putIfAbsent(link, () {
return TapGestureRecognizer()..onTap = () => widget.onLinkTap(link.href);
});
}
}
/// A form rendered from the page model. Submitting sends the field values to
/// the browser controller; reset restores the author's initial values.
class _MewsForm extends StatefulWidget {
const _MewsForm({
required this.form,
required this.foreground,
required this.onFormSubmit,
});
final FormBlock form;
final TextStyle foreground;
final void Function(FormBlock form, Map<String, String> values) onFormSubmit;
@override
State<_MewsForm> createState() => _MewsFormState();
}
class _MewsFormState extends State<_MewsForm> {
final _controllers = <String, TextEditingController>{};
final _values = <String, String>{};
@override
void dispose() {
for (final controller in _controllers.values) {
controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final strings = StringsScope.of(context);
final form = widget.form;
final style = widget.foreground;
return Form(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final field in form.fields) _field(field, style),
for (final field in form.fields.whereType<MewsSubmitField>())
Padding(
padding: const EdgeInsets.only(top: 8),
child: FilledButton(
onPressed: () => field.reset ? _reset(form) : _submit(form),
child: Text(
field.label ??
(field.reset ? strings.t('form-reset-default') : strings.t('form-submit-default')),
),
),
),
],
),
);
}
Widget _field(MewsFormField field, TextStyle style) {
Widget control = switch (field) {
MewsTextField(:final name, :final password, :final value, :final maxLength) => TextFormField(
controller: _controllers.putIfAbsent(
name, () => TextEditingController(text: _values.putIfAbsent(name, () => value))),
obscureText: password,
maxLength: maxLength,
style: style,
decoration: InputDecoration(labelText: field.label, isDense: true),
),
MewsTextAreaField(:final name, :final value, :final rows) => TextFormField(
controller: _controllers.putIfAbsent(
name, () => TextEditingController(text: _values.putIfAbsent(name, () => value))),
minLines: rows,
maxLines: rows,
style: style,
decoration: InputDecoration(labelText: field.label, isDense: true),
),
MewsCheckboxField(:final name, :final checked) => CheckboxListTile(
title: field.label == null ? null : Text(field.label!, style: style),
value: _values.putIfAbsent(name, () => checked ? 'on' : '') == 'on',
onChanged: (value) => setState(() => _values[name] = value == true ? 'on' : ''),
),
MewsRadioField(:final name, :final value, :final checked) => RadioGroup<String>(
groupValue: _values.putIfAbsent(name, () => checked ? value : ''),
onChanged: (value) => setState(() => _values[name] = value ?? ''),
child: RadioListTile<String>(
value: value,
title: field.label == null ? null : Text(field.label!, style: style),
),
),
MewsSelectField(:final name, :final options) => options.isEmpty
? const SizedBox.shrink()
: DropdownButtonFormField<String>(
initialValue: _values.putIfAbsent(
name,
() => options.firstWhere((o) => o.selected, orElse: () => options.first).value),
items: [
for (final option in options) DropdownMenuItem(value: option.value, child: Text(option.value)),
],
onChanged: (value) => _values[name] = value ?? '',
decoration: InputDecoration(labelText: field.label, isDense: true),
),
MewsSubmitField() => const SizedBox.shrink(),
MewsHiddenField() => const SizedBox.shrink(),
};
return control;
}
void _submit(FormBlock form) {
final values = <String, String>{};
for (final field in form.fields) {
switch (field) {
case MewsTextField(:final name):
values[name] = _controllers[name]?.text ?? '';
case MewsTextAreaField(:final name):
values[name] = _controllers[name]?.text ?? '';
case MewsCheckboxField(:final name, :final value):
if (_values[name] == 'on') values[name] = value;
case MewsRadioField(:final name, :final value):
if (_values[name] == value) values[name] = value;
case MewsSelectField(:final name):
values[name] = _values[name] ?? '';
case MewsHiddenField(:final name, :final value):
values[name] = value;
case MewsSubmitField():
break;
}
}
widget.onFormSubmit(form, values);
}
void _reset(FormBlock form) {
setState(() {
_values.clear();
for (final controller in _controllers.values) {
controller.clear();
}
// Restore the author's initial text field values.
for (final field in form.fields) {
switch (field) {
case MewsTextField(:final name, :final value):
_controllers[name]?.text = value;
case MewsTextAreaField(:final name, :final value):
_controllers[name]?.text = value;
default:
break;
}
}
});
}
}

View file

@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import '../../mews/document.dart';
/// The client's own presentation, mirroring the palettes of the default
/// stylesheet mews-0.1.css (spec 5.1). Mews clients ignore author CSS and use
/// their own, so these colors are the single visual source for page content.
class MewsPalette {
const MewsPalette({
required this.background,
required this.foreground,
required this.link,
required this.visited,
required this.border,
required this.preBackground,
});
final Color background;
final Color foreground;
final Color link;
final Color visited;
final Color border;
final Color preBackground;
}
const _plainLight = MewsPalette(
background: Color(0xfffaf8f3),
foreground: Color(0xff1f1f1f),
link: Color(0xff1a4f9c),
visited: Color(0xff5b2a86),
border: Color(0xffc9c4b8),
preBackground: Color(0xffefece4),
);
const _plainDark = MewsPalette(
background: Color(0xff18191b),
foreground: Color(0xffe3e1dc),
link: Color(0xff8fb8f5),
visited: Color(0xffc9a3f2),
border: Color(0xff45464a),
preBackground: Color(0xff222326),
);
const _warmLight = MewsPalette(
background: Color(0xfffbf4e8),
foreground: Color(0xff2a2420),
link: Color(0xff8a3d14),
visited: Color(0xff5b2a86),
border: Color(0xffc9c4b8),
preBackground: Color(0xffefece4),
);
const _warmDark = MewsPalette(
background: Color(0xff1d1916),
foreground: Color(0xffe8dfd3),
link: Color(0xfff0b48a),
visited: Color(0xffc9a3f2),
border: Color(0xff45464a),
preBackground: Color(0xff222326),
);
const _coolLight = MewsPalette(
background: Color(0xfff3f6fa),
foreground: Color(0xff1c2430),
link: Color(0xff1a4f9c),
visited: Color(0xff5b2a86),
border: Color(0xffc9c4b8),
preBackground: Color(0xffefece4),
);
const _coolDark = MewsPalette(
background: Color(0xff161a20),
foreground: Color(0xffdde4ee),
link: Color(0xff8fb8f5),
visited: Color(0xffc9a3f2),
border: Color(0xff45464a),
preBackground: Color(0xff222326),
);
const _greenLight = MewsPalette(
background: Color(0xfff2f6ef),
foreground: Color(0xff1f2a1f),
link: Color(0xff24502a),
visited: Color(0xff5b2a86),
border: Color(0xffc9c4b8),
preBackground: Color(0xffefece4),
);
const _greenDark = MewsPalette(
background: Color(0xff161b16),
foreground: Color(0xffdbe6d8),
link: Color(0xff9fd39a),
visited: Color(0xffc9a3f2),
border: Color(0xff45464a),
preBackground: Color(0xff222326),
);
const _monoLight = MewsPalette(
background: Color(0xffe8ebe1),
foreground: Color(0xff1e2219),
link: Color(0xff1e2219),
visited: Color(0xff5b2a86),
border: Color(0xffc9c4b8),
preBackground: Color(0xffefece4),
);
const _monoDark = MewsPalette(
background: Color(0xff1b1d18),
foreground: Color(0xffc8d0b8),
link: Color(0xffc8d0b8),
visited: Color(0xffc9a3f2),
border: Color(0xff45464a),
preBackground: Color(0xff222326),
);
/// Resolves the palette: the reader's variant choice overrides the page's own
/// variant unless the choice is "site" (spec 5.3, 8.2).
MewsPalette paletteFor({required MewsVariant pageVariant, required String readerVariant, required bool dark}) {
final variant = readerVariant == 'site' ? pageVariant.name : pageVariantFromName(readerVariant);
return switch ((variant, dark)) {
(MewsVariant.warm, false) => _warmLight,
(MewsVariant.warm, true) => _warmDark,
(MewsVariant.cool, false) => _coolLight,
(MewsVariant.cool, true) => _coolDark,
(MewsVariant.green, false) => _greenLight,
(MewsVariant.green, true) => _greenDark,
(MewsVariant.mono, false) => _monoLight,
(MewsVariant.mono, true) => _monoDark,
(_, false) => _plainLight,
(_, true) => _plainDark,
};
}
MewsVariant pageVariantFromName(String name) => switch (name) {
'warm' => MewsVariant.warm,
'cool' => MewsVariant.cool,
'green' => MewsVariant.green,
'mono' => MewsVariant.mono,
_ => MewsVariant.plain,
};

View file

@ -0,0 +1,15 @@
/// The bundled typefaces the reader can choose from (spec 8.2). Adding a font
/// is one TTF in assets/fonts, one entry in pubspec.yaml and one entry here.
class ReaderFont {
const ReaderFont(this.family, this.labelId);
/// The pubspec font family name.
final String family;
/// Message ID of its display name in locales/en-US/main.ftl.
final String labelId;
}
const readerFonts = [
ReaderFont('Atkinson Hyperlegible', 'settings-font-atkinson'),
];

View 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)));
});
}
}

View file

@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart' show SignalBuilder;
import '../../application/controllers/reader_settings_controller.dart';
import '../app.dart';
import 'reader_fonts.dart';
/// Opens the reader settings bottom sheet: fonts, text size, theme, colors,
/// bookmark and subscription for the current page.
void showReaderSettingsSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (context) => const SettingsSheet(),
);
}
class SettingsSheet extends StatelessWidget {
const SettingsSheet({super.key});
@override
Widget build(BuildContext context) {
final scope = AppScope.of(context);
final deps = scope.deps;
final strings = scope.strings;
return SignalBuilder(builder: (context) {
final settings = deps.settings;
final library = deps.library;
final page = deps.browser.currentPage.value;
final url = page?.url;
final percent = (settings.fontScale.value * 100).round();
return SafeArea(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(0, 12, 0, 16),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
strings.t('settings-sheet-title'),
style: Theme.of(context).textTheme.titleMedium,
),
),
_heading(context, strings.t('settings-font-heading')),
RadioGroup<String>(
groupValue: settings.font.value,
onChanged: (value) => settings.setFont(value!),
child: Column(
children: [
for (final font in readerFonts)
RadioListTile<String>(
value: font.family,
title: Text(strings.t(font.labelId)),
),
],
),
),
_heading(context, strings.t('settings-size-heading')),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
tooltip: strings.t('settings-size-decrease'),
onPressed: settings.fontScale.value > ReaderSettingsController.minScale
? settings.decreaseSize
: null,
icon: const Icon(Icons.remove),
),
SizedBox(
width: 56,
child: Text(
'$percent%',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
),
IconButton(
tooltip: strings.t('settings-size-increase'),
onPressed: settings.fontScale.value < ReaderSettingsController.maxScale
? settings.increaseSize
: null,
icon: const Icon(Icons.add),
),
],
),
_heading(context, strings.t('settings-theme-heading')),
RadioGroup<String>(
groupValue: settings.themeMode.value,
onChanged: (value) => settings.setThemeMode(value!),
child: Column(
children: [
for (final (mode, id) in [
('light', 'settings-theme-light'),
('dark', 'settings-theme-dark'),
('system', 'settings-theme-system'),
])
RadioListTile<String>(
value: mode,
title: Text(strings.t(id)),
),
],
),
),
_heading(context, strings.t('settings-variant-heading')),
RadioGroup<String>(
groupValue: settings.variant.value,
onChanged: (value) => settings.setVariant(value!),
child: Column(
children: [
for (final (variant, id) in [
('site', 'settings-variant-site'),
('warm', 'settings-variant-warm'),
('cool', 'settings-variant-cool'),
('green', 'settings-variant-green'),
('mono', 'settings-variant-mono'),
])
RadioListTile<String>(
value: variant,
title: Text(strings.t(id)),
),
],
),
),
const Divider(),
if (url != null) ...[
ListTile(
leading: Icon(library.isBookmarked(url) ? Icons.star : Icons.star_border),
title: Text(
library.isBookmarked(url)
? strings.t('settings-bookmark-remove')
: strings.t('settings-bookmark-add'),
),
onTap: () => library.toggleBookmark(url, page?.title),
),
ListTile(
leading: Icon(library.isSubscribed(url)
? Icons.notifications_active
: Icons.notifications_outlined),
title: Text(
library.isSubscribed(url)
? strings.t('settings-unsubscribe')
: strings.t('settings-subscribe'),
),
subtitle: library.isSubscribed(url)
? Text(strings.t('settings-subscribed-notice'))
: null,
onTap: () => library.toggleSubscription(url, page?.title, page?.entries ?? const []),
),
],
],
),
);
});
}
Widget _heading(BuildContext context, String text) => Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
text,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: Theme.of(context).colorScheme.primary),
),
);
}