the-mews/app/lib/application/pages/page_blocks.dart

191 lines
6.1 KiB
Dart

/// The shared vocabulary every internal page is built from. Internal pages are
/// ordinary Mews documents, so a page has only headings, lists, paragraphs and
/// links to work with; these builders keep the result consistent.
library;
import '../../l10n/strings.dart';
import '../../mews/document.dart';
import 'mews_urls.dart';
/// Separates independent labels on one line. A separator is layout, so it is a
/// constant here rather than a message.
const actionSeparator = ' ';
/// A link with its label: a choice in a group, or an action on a row. For an
/// action [current] is always false.
typedef Choice = ({String href, String label, bool current});
/// An action link on a row.
Choice action(String href, String label) =>
(href: href, label: label, current: false);
/// One entry in the strip of internal pages.
typedef NavItem = ({String url, String labelId});
/// The strip carried by every internal page, in a fixed order so its position
/// never shifts under the reader.
const navItems = <NavItem>[
(url: MewsUrls.hub, labelId: 'nav-hub'),
(url: MewsUrls.posts, labelId: 'nav-posts'),
(url: MewsUrls.bookmarks, labelId: 'nav-bookmarks'),
(url: MewsUrls.subscriptions, labelId: 'nav-subscriptions'),
(url: MewsUrls.history, labelId: 'nav-history'),
(url: MewsUrls.settings, labelId: 'nav-settings'),
];
/// The title and the navigation strip that open every internal page. The page
/// the reader is on is plain text, matching how a current value is marked.
List<MewsBlock> chrome({
required String title,
required Uri current,
required Strings strings,
}) {
final inline = <MewsInline>[];
for (final item in navItems) {
if (inline.isNotEmpty) inline.add(const MewsText(actionSeparator));
final label = strings.t(item.labelId);
// Compare paths so a filtered or sorted page still marks its own entry.
if (Uri.parse(item.url).path == current.path) {
inline.add(MewsEmphasis(EmphasisStyle.strong, [MewsText(label)]));
} else {
inline.add(MewsLink(item.url, [MewsText(label)]));
}
}
return [
HeadingBlock(1, [MewsText(title)]),
ParagraphBlock(inline),
const RuleBlock(),
];
}
/// One row in a list: the item, then an optional note on its own line. A date
/// goes inside the link, the way a Mews page writes its own dated links (spec
/// 6.1), so the whole row is one target. One paragraph per row keeps rows
/// close together, since each paragraph carries its own bottom padding.
MewsListItem itemRow({
required String href,
required String label,
String? date,
String? note,
bool strong = false,
List<Choice> actions = const [],
}) {
final text = date == null ? label : '$date $label';
return MewsListItem([
ParagraphBlock([
MewsLink(href, [
if (strong)
MewsEmphasis(EmphasisStyle.strong, [MewsText(text)])
else
MewsText(text),
]),
if (note != null) ...[
const MewsLineBreak(),
MewsEmphasis(EmphasisStyle.emphasis, [MewsText(note)]),
],
if (actions.isNotEmpty) ...[
const MewsLineBreak(),
..._separated(
[for (final action in actions) MewsLink(action.href, [MewsText(action.label)])]),
],
]),
]);
}
/// Joins runs with the separator between them, never around them.
List<MewsInline> _separated(List<MewsInline> runs) {
final out = <MewsInline>[];
for (final run in runs) {
if (out.isNotEmpty) out.add(const MewsText(actionSeparator));
out.add(run);
}
return out;
}
/// A group of mutually exclusive values on one line, for a short group that
/// would waste a list. The chosen value is plain text, the rest are links.
List<MewsBlock> choiceLine({required String label, required List<Choice> options}) {
return [
ParagraphBlock([
MewsEmphasis(EmphasisStyle.emphasis, [MewsText(label)]),
const MewsText(actionSeparator),
..._separated([
for (final option in options)
if (option.current)
MewsEmphasis(EmphasisStyle.strong, [MewsText(option.label)])
else
MewsLink(option.href, [MewsText(option.label)]),
]),
]),
];
}
/// A group of mutually exclusive values: the current one plain, the rest links.
ListBlock choiceRow(List<Choice> options) {
return ListBlock(
ordered: false,
start: 1,
items: [
for (final option in options)
MewsListItem([
ParagraphBlock([
if (option.current)
MewsEmphasis(EmphasisStyle.strong, [MewsText(option.label)])
else
MewsLink(option.href, [MewsText(option.label)]),
]),
]),
],
);
}
/// A plain list of links, used for menus and library lists alike.
ListBlock itemList(List<MewsListItem> items) =>
ListBlock(ordered: false, start: 1, items: items);
/// Wraps blocks as an internal document. The marker makes the renderer treat
/// it as a Mews page rather than prepending the "not a Mews page" notice, and
/// no entries are offered so an internal page is never subscribable.
MewsPage internalPage({
required Uri url,
required String title,
required List<MewsBlock> body,
}) {
return MewsPage(
url: url.toString(),
specVersion: 'internal',
title: title,
body: body,
lang: 'en',
direction: TextDirection.ltr,
variant: MewsVariant.plain,
entries: const [],
);
}
/// Picks the message for [count]. Plurals are separate messages, so the
/// choice happens here and Fluent formats the number.
String countText(
Strings strings,
int count, {
required String none,
required String one,
required String many,
}) {
return switch (count) {
0 => strings.t(none),
1 => strings.t(one),
_ => strings.t(many, {'count': count}),
};
}
/// YYYY-MM-DD, the shape a Mews entry link uses (spec 6.1).
String dayText(DateTime date) =>
'${date.year.toString().padLeft(4, '0')}-${_two(date.month)}-${_two(date.day)}';
String clockText(DateTime date) => '${_two(date.hour)}:${_two(date.minute)}';
/// The day and the time of day, for history rows.
String dayTimeText(DateTime date) => '${dayText(date)} ${clockText(date)}';
String _two(int value) => value.toString().padLeft(2, '0');