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
100
app/lib/presentation/app.dart
Normal file
100
app/lib/presentation/app.dart
Normal 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(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
80
app/lib/presentation/reader/address_bar.dart
Normal file
80
app/lib/presentation/reader/address_bar.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
34
app/lib/presentation/reader/fob.dart
Normal file
34
app/lib/presentation/reader/fob.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
482
app/lib/presentation/reader/mews_page_view.dart
Normal file
482
app/lib/presentation/reader/mews_page_view.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
140
app/lib/presentation/reader/palettes.dart
Normal file
140
app/lib/presentation/reader/palettes.dart
Normal 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,
|
||||
};
|
||||
15
app/lib/presentation/reader/reader_fonts.dart
Normal file
15
app/lib/presentation/reader/reader_fonts.dart
Normal 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'),
|
||||
];
|
||||
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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
169
app/lib/presentation/reader/settings_sheet.dart
Normal file
169
app/lib/presentation/reader/settings_sheet.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue