feat: store subscription posts with a read mark and split the library controller

This commit is contained in:
randogoth 2026-10-11 13:12:48 +03:00
parent b311b9f7dd
commit 953ae798eb
15 changed files with 1787 additions and 386 deletions

View file

@ -20,6 +20,8 @@ A Flutter browser/client for Mews Pages (see mews-spec).
Local-first: all data is stored locally in a Drift database. Network operations are secondary and never block local functionality.
Posts found on a subscribed page are stored per site with a read mark, so an unread post survives a restart. A post's address identifies it for its lifetime (spec 6.1), so finding it again never clears its mark. Subscribing to a site stores what it already published as read, so a new subscription arrives without a backlog.
# Architecture directives
Keep files small and focused.

View file

@ -0,0 +1,35 @@
import 'package:signals/signals.dart';
import '../../database/database.dart';
/// Which pages the reader has bookmarked. Only the set of addresses is held
/// here, because that is all the chrome watches; the list itself is read from
/// the database whenever a page needs it.
class BookmarksController {
BookmarksController(this._db);
final MewsDatabase _db;
final bookmarkedUrls = signal(<String>{});
Future<void> load() async {
final rows = await _db.allBookmarks();
bookmarkedUrls.value = rows.map((row) => row.url).toSet();
}
bool isBookmarked(String url) => bookmarkedUrls.value.contains(url);
Future<void> toggle(String url, String? title) async {
if (isBookmarked(url)) {
await remove(url);
return;
}
await _db.addBookmark(url, title);
bookmarkedUrls.value = {...bookmarkedUrls.value, url};
}
Future<void> remove(String url) async {
await _db.removeBookmark(url);
bookmarkedUrls.value = {...bookmarkedUrls.value}..remove(url);
}
}

View file

@ -1,9 +1,6 @@
import 'dart:convert';
import 'package:signals/signals.dart';
import '../../database/database.dart';
import '../../mews/document.dart';
/// The reader's presentation choices (spec 8.2) plus the per-app limits they
/// move within. Values persist through ReaderSettings in the database.
@ -76,75 +73,3 @@ class ReaderSettingsController {
variant: variant.value,
);
}
/// Bookmarks and subscriptions. Signals expose derived state (which URLs are
/// bookmarked or subscribed); subscription refreshes piggyback on page loads,
/// comparing the page's dated links against the known entry URLs.
class LibraryController {
LibraryController(this._db);
final MewsDatabase _db;
final bookmarkedUrls = signal(<String>{});
final subscribedUrls = signal(<String>{});
/// New posts found on the most recent refresh of a subscribed site, keyed by
/// site URL. Empty when there is nothing to report.
final newEntries = signal(<String, List<MewsEntry>>{});
Future<void> load() async {
final bookmarks = await _db.allBookmarks();
bookmarkedUrls.value = bookmarks.map((b) => b.url).toSet();
final subs = await _db.allSubscriptions();
subscribedUrls.value = subs.map((s) => s.url).toSet();
}
bool isBookmarked(String url) => bookmarkedUrls.value.contains(url);
bool isSubscribed(String url) => subscribedUrls.value.contains(url);
Future<void> toggleBookmark(String url, String? title) async {
if (isBookmarked(url)) {
await _db.removeBookmark(url);
bookmarkedUrls.value = {...bookmarkedUrls.value}..remove(url);
} else {
await _db.addBookmark(url, title);
bookmarkedUrls.value = {...bookmarkedUrls.value, url};
}
}
Future<void> toggleSubscription(String url, String? title, List<MewsEntry> entries) async {
if (isSubscribed(url)) {
await _db.removeSubscription(url);
subscribedUrls.value = {...subscribedUrls.value}..remove(url);
newEntries.value = {...newEntries.value}..remove(url);
} else {
await _db.addSubscription(url, title, entries.map((e) => e.url).toList());
subscribedUrls.value = {...subscribedUrls.value, url};
newEntries.value = {...newEntries.value}..remove(url);
}
}
/// Revalidates every subscribed page and reports entries the reader has not
/// seen yet (spec 6.1). Called when a subscribed page is opened.
Future<void> refreshSubscription(MewsPage page) async {
final subs = await _db.allSubscriptions();
final sub = subs.where((s) => s.url == page.url).firstOrNull;
if (sub == null) return;
final known = (jsonDecode(sub.entryUrls) as List).cast<String>().toSet();
final fresh = page.entries.where((e) => !known.contains(e.url)).toList();
if (fresh.isEmpty) {
// Nothing new this time, so the site stops being flagged.
newEntries.value = {...newEntries.value}..remove(sub.url);
await _db.updateSubscription(sub.url, lastCheckedAt: DateTime.now());
return;
}
newEntries.value = {...newEntries.value, page.url: fresh};
await _db.updateSubscription(
sub.url,
entryUrls: [...known, ...fresh.map((e) => e.url)],
lastCheckedAt: DateTime.now(),
);
}
}

View file

@ -0,0 +1,50 @@
import 'package:signals/signals.dart';
import '../../database/database.dart';
import '../../mews/document.dart';
/// Which sites the reader subscribes to (spec 6). The posts themselves live in
/// the database, so an unread post survives a restart; only the set of
/// addresses is held here, because that is what the chrome watches.
class SubscriptionsController {
SubscriptionsController(this._db);
final MewsDatabase _db;
final subscribedUrls = signal(<String>{});
Future<void> load() async {
final rows = await _db.allSubscriptions();
subscribedUrls.value = rows.map((row) => row.url).toSet();
}
bool isSubscribed(String url) => subscribedUrls.value.contains(url);
Future<void> toggle(MewsPage page) async {
if (isSubscribed(page.url)) {
await remove(page.url);
return;
}
await _db.addSubscription(page.url, page.title, page.atomFeedHref);
subscribedUrls.value = {...subscribedUrls.value, page.url};
// The posts already on the page are the site's back catalogue, not news,
// so they are stored read and only later posts are flagged.
await _db.addEntries(page.url, page.entries, read: true);
}
Future<void> remove(String siteUrl) async {
await _db.removeSubscription(siteUrl);
subscribedUrls.value = {...subscribedUrls.value}..remove(siteUrl);
}
/// Records the posts on a subscribed page the reader just opened, and
/// returns the ones that are new. Does nothing for a site not subscribed to.
Future<List<MewsEntry>> refreshSite(MewsPage page) async {
if (!isSubscribed(page.url)) return const [];
await _db.describeSubscription(page.url,
title: page.title, atomHref: page.atomFeedHref);
final fresh = await _db.addEntries(page.url, page.entries);
await _db.touchSubscription(page.url, DateTime.now());
return fresh;
}
}

View file

@ -2,7 +2,7 @@ import '../database/database.dart';
import '../l10n/strings.dart';
import '../mews/document.dart';
import 'controllers/reader_settings_controller.dart'
show LibraryController, ReaderSettingsController;
show ReaderSettingsController;
import 'pages/bookmarks_page.dart';
import 'pages/history_page.dart';
import 'pages/hub_page.dart';
@ -16,16 +16,13 @@ import 'pages/subscriptions_page.dart';
class InternalPages {
InternalPages({
required MewsDatabase db,
required LibraryController library,
required ReaderSettingsController settings,
required Strings strings,
}) : _db = db,
_library = library,
_settings = settings,
_strings = strings;
final MewsDatabase _db;
final LibraryController _library;
final ReaderSettingsController _settings;
final Strings _strings;
@ -39,8 +36,7 @@ class InternalPages {
return switch (uri.path) {
'hub' => buildHubPage(uri, db: _db, strings: _strings),
'bookmarks' => buildBookmarksPage(uri, db: _db, strings: _strings),
'subscriptions' => buildSubscriptionsPage(uri,
db: _db, library: _library, strings: _strings),
'subscriptions' => buildSubscriptionsPage(uri, db: _db, strings: _strings),
'history' => buildHistoryPage(uri, db: _db, strings: _strings),
'settings' =>
buildSettingsPage(uri, settings: _settings, strings: _strings),

View file

@ -1,16 +1,15 @@
import '../../database/database.dart';
import '../../l10n/strings.dart';
import '../../mews/document.dart';
import '../controllers/reader_settings_controller.dart' show LibraryController;
import 'page_blocks.dart';
Future<MewsPage> buildSubscriptionsPage(
Uri url, {
required MewsDatabase db,
required LibraryController library,
required Strings strings,
}) async {
final sites = await db.allSubscriptions();
final unread = await db.unreadCounts();
final title = strings.t('subscriptions-title');
return internalPage(
@ -27,17 +26,16 @@ Future<MewsPage> buildSubscriptionsPage(
href: site.url,
label: site.title ?? site.url,
date: dayText(site.createdAt),
note: _unreadNote(library, strings, site.url),
note: _unreadNote(strings, unread[site.url] ?? 0),
),
]),
],
);
}
/// The unread count for a site, or null when it has nothing new, so the row
/// carries no empty line.
String? _unreadNote(LibraryController library, Strings strings, String siteUrl) {
final count = library.newEntries.value[siteUrl]?.length ?? 0;
/// The unread count for a site, or null when it has nothing unread, so the
/// row carries no second line.
String? _unreadNote(Strings strings, int count) {
if (count == 0) return null;
return countText(strings, count,
none: 'count-unread-none',

View file

@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart';
@ -6,9 +5,14 @@ import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../mews/document.dart' show MewsEntry;
part 'database.g.dart';
/// Visited pages, newest first; the most recent row is the app's start page.
/// The same page can appear many times, so the index carries the lookups that
/// group or remove by address.
@TableIndex(name: 'history_items_url', columns: {#url})
class HistoryItems extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get url => text()();
@ -16,22 +20,59 @@ class HistoryItems extends Table {
DateTimeColumn get visitedAt => dateTime().withDefault(currentDateAndTime)();
}
/// A bookmarked page, keyed by its address so it can only be held once.
class Bookmarks extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get url => text()();
TextColumn get title => text().nullable()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {url};
}
/// A subscription to one page (spec 6). [entryUrls] is a JSON list of the
/// entry URLs already seen, so a refresh can report only new posts.
/// A subscribed page (spec 6). Its posts live in [FeedEntries].
class Subscriptions extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get url => text()();
TextColumn get title => text().nullable()();
TextColumn get entryUrls => text().withDefault(const Constant('[]'))();
/// The page's Atom feed, resolved absolute, when it declares one (spec 6.2).
TextColumn get atomHref => text().nullable()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get lastCheckedAt => dateTime().nullable()();
@override
Set<Column> get primaryKey => {url};
}
/// One post found on a subscribed page, through its dated links (spec 6.1) or
/// its Atom feed (spec 6.2). An entry's URL identifies it for its lifetime, so
/// (siteUrl, url) is its identity and finding it again never clears [readAt].
class FeedEntries extends Table {
TextColumn get siteUrl => text()();
TextColumn get url => text()();
TextColumn get title => text()();
DateTimeColumn get date => dateTime()();
DateTimeColumn get foundAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get readAt => dateTime().nullable()();
@override
Set<Column> get primaryKey => {siteUrl, url};
}
/// What a tag is attached to.
enum TagKind { bookmark, subscription }
/// Tags the reader puts on a bookmark or a subscribed site. A tag exists only
/// while something carries it, so there is no separate table of tags to keep
/// in step. Comparison is case-sensitive.
class ItemTags extends Table {
TextColumn get kind => textEnum<TagKind>()();
TextColumn get url => text()();
TextColumn get tag => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {kind, url, tag};
}
/// Single-row table (id 0) holding the reader's presentation choices.
@ -59,7 +100,15 @@ class CachedPages extends Table {
Set<Column> get primaryKey => {url};
}
@DriftDatabase(tables: [HistoryItems, Bookmarks, Subscriptions, ReaderSettings, CachedPages])
@DriftDatabase(tables: [
HistoryItems,
Bookmarks,
Subscriptions,
FeedEntries,
ItemTags,
ReaderSettings,
CachedPages,
])
class MewsDatabase extends _$MewsDatabase {
MewsDatabase() : super(_open());
@ -67,7 +116,13 @@ class MewsDatabase extends _$MewsDatabase {
MewsDatabase.test(super.executor);
@override
int get schemaVersion => 1;
int get schemaVersion => 2;
/// Pre-release: the entry store replaces the old list of seen entry URLs and
/// the library tables gain real primary keys, so an existing database is
/// recreated rather than migrated.
@override
MigrationStrategy get migration => destructiveFallback;
static QueryExecutor _open() => LazyDatabase(() async {
final dir = await getApplicationDocumentsDirectory();
@ -92,14 +147,17 @@ class MewsDatabase extends _$MewsDatabase {
}
/// Row counts for the hub, so it can say what each page holds without
/// reading every row.
Future<int> countHistory() => _countOf(historyItems, historyItems.id);
Future<int> countBookmarks() => _countOf(bookmarks, bookmarks.id);
Future<int> countSubscriptions() => _countOf(subscriptions, subscriptions.id);
/// reading every row. History counts addresses, not visits.
Future<int> countHistory() =>
_countOf(historyItems, historyItems.url, distinct: true);
Future<int> countBookmarks() => _countOf(bookmarks, bookmarks.url);
Future<int> countSubscriptions() => _countOf(subscriptions, subscriptions.url);
Future<int> _countOf(ResultSetImplementation<HasResultSet, dynamic> table,
GeneratedColumn<int> column) async {
final total = column.count();
Future<int> _countOf<T extends Object>(
ResultSetImplementation<HasResultSet, dynamic> table,
GeneratedColumn<T> column,
{bool distinct = false}) async {
final total = column.count(distinct: distinct);
final query = selectOnly(table)..addColumns([total]);
return (await query.getSingle()).read(total) ?? 0;
}
@ -120,7 +178,8 @@ class MewsDatabase extends _$MewsDatabase {
final query = (select(bookmarks)
..orderBy([
(r) => OrderingTerm.desc(r.createdAt),
(r) => OrderingTerm.desc(r.id),
// Tiebreak bookmarks added within the same clock tick.
(r) => OrderingTerm.asc(r.url),
]));
return query.get();
}
@ -133,30 +192,153 @@ class MewsDatabase extends _$MewsDatabase {
BookmarksCompanion.insert(url: url, title: Value(title)),
mode: InsertMode.insertOrIgnore);
Future<void> removeBookmark(String url) =>
(delete(bookmarks)..where((r) => r.url.equals(url))).go();
/// Removes a bookmark and whatever the reader tagged it with.
Future<void> removeBookmark(String url) => transaction(() async {
await (delete(bookmarks)..where((r) => r.url.equals(url))).go();
await _removeTagsOf(TagKind.bookmark, url);
});
// Subscriptions ---------------------------------------------------------
Future<void> addSubscription(String url, String? title, List<String> entryUrls) => into(
subscriptions)
.insert(
Future<void> addSubscription(String url, String? title, String? atomHref) =>
into(subscriptions).insert(
SubscriptionsCompanion.insert(
url: url, title: Value(title), entryUrls: Value(jsonEncode(entryUrls))),
url: url, title: Value(title), atomHref: Value(atomHref)),
mode: InsertMode.insertOrIgnore);
Future<void> removeSubscription(String url) =>
(delete(subscriptions)..where((r) => r.url.equals(url))).go();
/// Removes a subscription along with its posts and tags, so nothing is left
/// pointing at a site the reader has left.
Future<void> removeSubscription(String url) => transaction(() async {
await (delete(subscriptions)..where((r) => r.url.equals(url))).go();
await (delete(feedEntries)..where((r) => r.siteUrl.equals(url))).go();
await _removeTagsOf(TagKind.subscription, url);
});
Future<List<Subscription>> allSubscriptions() => select(subscriptions).get();
Future<List<Subscription>> allSubscriptions() async {
final query = (select(subscriptions)
..orderBy([
(r) => OrderingTerm.desc(r.createdAt),
(r) => OrderingTerm.asc(r.url),
]));
return query.get();
}
Future<void> updateSubscription(String url,
{String? title, List<String>? entryUrls, DateTime? lastCheckedAt}) =>
(update(subscriptions)..where((r) => r.url.equals(url))).write(SubscriptionsCompanion(
title: Value(title),
entryUrls: entryUrls == null ? const Value.absent() : Value(jsonEncode(entryUrls)),
lastCheckedAt: Value(lastCheckedAt),
/// Records that a site was checked, whether or not anything was found.
Future<void> touchSubscription(String url, DateTime checkedAt) =>
(update(subscriptions)..where((r) => r.url.equals(url)))
.write(SubscriptionsCompanion(lastCheckedAt: Value(checkedAt)));
/// Updates what is known about a site. Each field is left alone when it is
/// not given, so a refresh cannot blank a stored title.
Future<void> describeSubscription(String url,
{String? title, String? atomHref}) =>
(update(subscriptions)..where((r) => r.url.equals(url))).write(
SubscriptionsCompanion(
title: title == null ? const Value.absent() : Value(title),
atomHref: atomHref == null ? const Value.absent() : Value(atomHref),
),
);
// Posts -------------------------------------------------------------------
/// Stores the posts of [siteUrl] that are not held yet and returns them, so
/// the caller can say what is new. An entry already held keeps its read
/// mark. Pass [read] to store them already read, which is how a new
/// subscription takes on a site's back catalogue without flagging it.
Future<List<MewsEntry>> addEntries(String siteUrl, List<MewsEntry> entries,
{bool read = false}) async {
if (entries.isEmpty) return const [];
final urls = entries.map((e) => e.url).toList();
final held = await (select(feedEntries)
..where((r) => r.siteUrl.equals(siteUrl) & r.url.isIn(urls)))
.get();
final known = held.map((r) => r.url).toSet();
final fresh = entries.where((e) => !known.contains(e.url)).toList();
if (fresh.isEmpty) return const [];
await batch((b) => b.insertAll(
feedEntries,
[
for (final entry in fresh)
FeedEntriesCompanion.insert(
siteUrl: siteUrl,
url: entry.url,
title: entry.title,
date: entry.date,
readAt: Value(read ? DateTime.now() : null),
),
],
mode: InsertMode.insertOrIgnore,
));
return fresh;
}
/// Marks a post read. The address identifies a post for its lifetime, so
/// the site is only needed when two sites happen to list the same post.
Future<void> markEntryRead(String url, {String? siteUrl}) {
final statement = update(feedEntries)..where((r) => r.url.equals(url));
if (siteUrl != null) statement.where((r) => r.siteUrl.equals(siteUrl));
return statement.write(FeedEntriesCompanion(readAt: Value(DateTime.now())));
}
Future<void> markEntryUnread(String url, {String? siteUrl}) {
final statement = update(feedEntries)..where((r) => r.url.equals(url));
if (siteUrl != null) statement.where((r) => r.siteUrl.equals(siteUrl));
return statement.write(const FeedEntriesCompanion(readAt: Value(null)));
}
/// How many posts are unread, for one site or across all of them.
Future<int> unreadCount({String? siteUrl}) async {
final total = feedEntries.url.count();
final query = selectOnly(feedEntries)
..addColumns([total])
..where(feedEntries.readAt.isNull());
if (siteUrl != null) query.where(feedEntries.siteUrl.equals(siteUrl));
return (await query.getSingle()).read(total) ?? 0;
}
/// The unread count per site, so a list of sites costs one query.
Future<Map<String, int>> unreadCounts() async {
final total = feedEntries.url.count();
final query = selectOnly(feedEntries)
..addColumns([feedEntries.siteUrl, total])
..where(feedEntries.readAt.isNull())
..groupBy([feedEntries.siteUrl]);
return {
for (final row in await query.get())
row.read(feedEntries.siteUrl)!: row.read(total) ?? 0,
};
}
// Tags ---------------------------------------------------------------------
/// The tags on one item, in the order they were added.
Future<List<String>> tagsOf(TagKind kind, String url) async {
final query = (select(itemTags)
..where((r) => r.kind.equalsValue(kind) & r.url.equals(url))
..orderBy([
(r) => OrderingTerm.asc(r.createdAt),
(r) => OrderingTerm.asc(r.tag),
]));
return (await query.get()).map((row) => row.tag).toList();
}
Future<void> addTag(TagKind kind, String url, String tag) =>
into(itemTags).insert(
ItemTagsCompanion.insert(kind: kind, url: url, tag: tag),
mode: InsertMode.insertOrIgnore);
Future<void> removeTag(TagKind kind, String url, String tag) =>
(delete(itemTags)
..where((r) =>
r.kind.equalsValue(kind) &
r.url.equals(url) &
r.tag.equals(tag)))
.go();
Future<void> _removeTagsOf(TagKind kind, String url) =>
(delete(itemTags)
..where((r) => r.kind.equalsValue(kind) & r.url.equals(url)))
.go();
// Settings ---------------------------------------------------------------

File diff suppressed because it is too large Load diff

View file

@ -3,8 +3,10 @@ import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart' show SignalBuilder;
import '../application/actors/fetch_actor.dart';
import '../application/controllers/bookmarks_controller.dart';
import '../application/controllers/browser_controller.dart';
import '../application/controllers/reader_settings_controller.dart';
import '../application/controllers/subscriptions_controller.dart';
import '../application/internal_actions.dart';
import '../application/internal_pages.dart';
import '../database/database.dart';
@ -24,9 +26,10 @@ class AppDependencies {
fetch = fetch ?? Actor.create(FetchHandler.new),
strings = strings ?? Strings() {
settings = ReaderSettingsController(db);
library = LibraryController(db);
internalPages = InternalPages(
db: db, library: library, settings: settings, strings: this.strings);
bookmarks = BookmarksController(db);
subscriptions = SubscriptionsController(db);
internalPages =
InternalPages(db: db, settings: settings, strings: this.strings);
internalActions = InternalActions(settings: settings);
browser = BrowserController(
db: db,
@ -43,13 +46,15 @@ class AppDependencies {
final Actor<FetchCommand, FetchResult> fetch;
late final BrowserController browser;
late final ReaderSettingsController settings;
late final LibraryController library;
late final BookmarksController bookmarks;
late final SubscriptionsController subscriptions;
late final InternalPages internalPages;
late final InternalActions internalActions;
Future<void> start() async {
await settings.load();
await library.load();
await bookmarks.load();
await subscriptions.load();
await browser.restoreLastSession();
}
}

View file

@ -132,28 +132,26 @@ class _AddressBarState extends State<AddressBar> {
if (canSubscribe)
IconButton(
icon: Icon(
deps.library.isSubscribed(page.url)
deps.subscriptions.isSubscribed(page.url)
? Icons.notifications_active
: Icons.notifications_outlined,
),
tooltip: deps.library.isSubscribed(page.url)
tooltip: deps.subscriptions.isSubscribed(page.url)
? strings.t('reader-unsubscribe')
: strings.t('reader-subscribe'),
onPressed: () => deps.library
.toggleSubscription(page.url, page.title, page.entries),
onPressed: () => deps.subscriptions.toggle(page),
),
if (canBookmark)
IconButton(
icon: Icon(
deps.library.isBookmarked(page.url)
deps.bookmarks.isBookmarked(page.url)
? Icons.star
: Icons.star_border,
),
tooltip: deps.library.isBookmarked(page.url)
tooltip: deps.bookmarks.isBookmarked(page.url)
? strings.t('reader-bookmark-remove')
: strings.t('reader-bookmark-add'),
onPressed: () =>
deps.library.toggleBookmark(page.url, page.title),
onPressed: () => deps.bookmarks.toggle(page.url, page.title),
),
],
),

View file

@ -28,7 +28,6 @@ class _ReaderScreenState extends State<ReaderScreen> {
/// scrolls down, back on any upward scroll.
bool _chromeVisible = true;
bool _showBrokenText = false;
final _reportedNewPosts = <String>{};
final _checkedSubscriptions = <String>{};
@override
@ -151,7 +150,6 @@ class _ReaderScreenState extends State<ReaderScreen> {
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,
@ -251,32 +249,15 @@ class _ReaderScreenState extends State<ReaderScreen> {
);
}
/// Revalidates a subscribed page once per page load so new entries surface
/// (spec 6.1). The library controller no-ops for unsubscribed pages.
/// Records the posts of a subscribed page once per page load, so what is new
/// shows up on the subscriptions and posts pages (spec 6.1). The controller
/// no-ops for a page the reader is not subscribed to.
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)));
if (mounted) deps.subscriptions.refreshSite(page);
});
}
}

View file

@ -72,12 +72,6 @@ settings-variant-cool = Cool
settings-variant-green = Green
settings-variant-mono = Mono
## New posts
# Shown when a subscribed site has new posts. Format the number in code.
new-posts-one = 1 new post is available on this site.
new-posts-many = { $count } new posts are available on this site.
## Forms
# Default label for a page form's submit control when the author gave none.

View file

@ -312,13 +312,13 @@ void main() {
expect(find.byIcon(Icons.notifications_outlined), findsOneWidget);
await tester.tap(find.byIcon(Icons.notifications_outlined));
await tester.pumpAndSettle();
expect(deps.library.isSubscribed('https://example.com/index.html'), isTrue);
expect(deps.subscriptions.isSubscribed('https://example.com/index.html'), isTrue);
expect(find.byIcon(Icons.notifications_active), findsOneWidget);
expect(find.byIcon(Icons.star_border), findsOneWidget);
await tester.tap(find.byIcon(Icons.star_border));
await tester.pumpAndSettle();
expect(deps.library.isBookmarked('https://example.com/index.html'), isTrue);
expect(deps.bookmarks.isBookmarked('https://example.com/index.html'), isTrue);
expect(find.byIcon(Icons.star), findsOneWidget);
// Pages with neither Atom feed nor dated links offer no subscription.
@ -464,10 +464,12 @@ void main() {
expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html');
// Bookmarks and subscriptions get the same treatment.
await stubDeps.library.load();
await stubDeps.library.toggleBookmark('https://example.com/b.html', 'Page B');
await stubDeps.library.toggleSubscription(
'https://example.com/a.html', 'Page A', const []);
await stubDeps.bookmarks.load();
await stubDeps.subscriptions.load();
await stubDeps.bookmarks.toggle('https://example.com/b.html', 'Page B');
await stubDeps.subscriptions.toggle(parseSource(
_page('Page A', '<h1>Page A</h1><p>The first page.</p>'),
'https://example.com/a.html'));
await stubDeps.browser.open('mews:bookmarks');
await tester.pumpAndSettle();
expect(find.text('Bookmarks'), findsOneWidget);
@ -518,23 +520,37 @@ void main() {
debugDefaultTargetPlatformOverride = null;
});
testWidgets('reports new posts once per page', (tester) async {
testWidgets('new posts are counted on the subscriptions page',
(tester) async {
await pumpApp(tester);
// Subscribing takes on what is already published without flagging it.
final page = parseSource(pageSource, 'https://example.com/index.html');
deps.library.subscribedUrls.value = {'https://example.com/index.html'};
deps.library.newEntries.value = {
'https://example.com/index.html': [
MewsEntry(
url: 'https://example.com/2026-10-08-retro.html',
date: DateTime(2026, 10, 8),
title: 'Collecting retro phones'),
],
};
deps.browser.currentPage.value = page;
await deps.subscriptions.toggle(page);
await deps.browser.open(MewsUrls.subscriptions);
await tester.pumpAndSettle();
expect(find.textContaining('unread post'), findsNothing);
expect(find.textContaining('1 new post'), findsOneWidget);
// Opening the page later, with a post that was not there before, counts
// it; the count comes from the database, so it outlives the session.
const laterSource = '''
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>Signal Bars</title><meta name="mews-profile" content="0.1" /></head>
<body>
<h1>Signal Bars</h1>
<ul>
<li><a href="2026-10-09-new.html">2026-10-09 A newer post</a></li>
<li><a href="2026-10-08-retro.html">2026-10-08 Collecting retro phones</a></li>
</ul>
</body>
</html>
''';
await deps.subscriptions
.refreshSite(parseSource(laterSource, 'https://example.com/index.html'));
await deps.browser.open(MewsUrls.subscriptions);
await tester.pumpAndSettle();
expect(find.textContaining('1 unread post'), findsOneWidget);
});
}

View file

@ -2,7 +2,9 @@ import 'package:drift/drift.dart' show Value;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mews/application/controllers/bookmarks_controller.dart';
import 'package:mews/application/controllers/reader_settings_controller.dart';
import 'package:mews/application/controllers/subscriptions_controller.dart';
import 'package:mews/database/database.dart';
import 'package:mews/mews/document.dart';
@ -45,44 +47,89 @@ void main() {
});
test('bookmarks toggle and persist', () async {
final library = LibraryController(db);
await library.load();
await library.toggleBookmark('https://example.com/', 'Example');
expect(library.isBookmarked('https://example.com/'), isTrue);
final bookmarks = BookmarksController(db);
await bookmarks.load();
await bookmarks.toggle('https://example.com/', 'Example');
expect(bookmarks.isBookmarked('https://example.com/'), isTrue);
final reloaded = LibraryController(db);
final reloaded = BookmarksController(db);
await reloaded.load();
expect(reloaded.isBookmarked('https://example.com/'), isTrue);
await reloaded.toggleBookmark('https://example.com/', 'Example');
await reloaded.toggle('https://example.com/', 'Example');
expect(reloaded.isBookmarked('https://example.com/'), isFalse);
});
test('subscription refresh reports only unseen entries', () async {
final library = LibraryController(db);
await library.load();
test('subscribing takes on the back catalogue without flagging it', () async {
final subscriptions = SubscriptionsController(db);
await subscriptions.load();
final firstPost = MewsEntry(
url: 'https://example.com/a.html', date: DateTime(2026, 9, 21), title: 'First post');
await library.toggleSubscription(
'https://example.com/', 'Example', [firstPost]);
expect(library.isSubscribed('https://example.com/'), isTrue);
url: 'https://example.com/a.html',
date: DateTime(2026, 9, 21),
title: 'First post');
await subscriptions.toggle(_page('https://example.com/', [firstPost]));
expect(subscriptions.isSubscribed('https://example.com/'), isTrue);
// Nothing new yet.
await library.refreshSubscription(_page('https://example.com/', [firstPost]));
expect(library.newEntries.value['https://example.com/'], isNull);
// The post was stored, but read, so nothing is waiting for the reader.
expect(await db.unreadCount(), 0);
// A new post appears.
// Opening the page again finds nothing new.
expect(await subscriptions.refreshSite(_page('https://example.com/', [firstPost])),
isEmpty);
// A later post is new, and stays unread until the reader opens it.
final newPost = MewsEntry(
url: 'https://example.com/b.html', date: DateTime(2026, 10, 8), title: 'Second post');
await library.refreshSubscription(_page('https://example.com/', [newPost, firstPost]));
final fresh = library.newEntries.value['https://example.com/']!;
expect(fresh, hasLength(1));
expect(fresh.single.url, 'https://example.com/b.html');
url: 'https://example.com/b.html',
date: DateTime(2026, 10, 8),
title: 'Second post');
final fresh = await subscriptions
.refreshSite(_page('https://example.com/', [newPost, firstPost]));
expect(fresh.map((e) => e.url), ['https://example.com/b.html']);
expect(await db.unreadCount(siteUrl: 'https://example.com/'), 1);
// The refresh stored it; a repeat refresh reports nothing.
await library.refreshSubscription(_page('https://example.com/', [newPost, firstPost]));
expect(library.newEntries.value['https://example.com/'], isNull);
// Finding it a second time does not report it again.
expect(
await subscriptions
.refreshSite(_page('https://example.com/', [newPost, firstPost])),
isEmpty);
expect(await db.unreadCount(), 1);
});
test('unread posts survive a restart', () async {
final subscriptions = SubscriptionsController(db);
await subscriptions.load();
await subscriptions.toggle(_page('https://example.com/', const []));
await subscriptions.refreshSite(_page('https://example.com/', [
MewsEntry(
url: 'https://example.com/a.html',
date: DateTime(2026, 9, 21),
title: 'First post'),
]));
// A fresh controller over the same database sees the same unread post,
// which the old in-memory count could not do.
final restarted = SubscriptionsController(db);
await restarted.load();
expect(restarted.isSubscribed('https://example.com/'), isTrue);
expect(await db.unreadCount(), 1);
});
test('leaving a site takes its posts with it', () async {
final subscriptions = SubscriptionsController(db);
await subscriptions.load();
await subscriptions.toggle(_page('https://example.com/', const []));
await subscriptions.refreshSite(_page('https://example.com/', [
MewsEntry(
url: 'https://example.com/a.html',
date: DateTime(2026, 9, 21),
title: 'First post'),
]));
expect(await db.unreadCount(), 1);
await subscriptions.remove('https://example.com/');
expect(subscriptions.isSubscribed('https://example.com/'), isFalse);
expect(await db.unreadCount(), 0);
});
test('caches pages for offline reopening', () async {

139
app/test/database_test.dart Normal file
View file

@ -0,0 +1,139 @@
import 'dart:io';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:mews/database/database.dart';
import 'package:mews/mews/document.dart';
MewsEntry _entry(String url, {String title = 'A post', int day = 8}) =>
MewsEntry(url: url, date: DateTime(2026, 10, day), title: title);
void main() {
late MewsDatabase db;
setUp(() {
db = MewsDatabase.test(NativeDatabase.memory());
});
tearDown(() async {
await db.close();
});
test('a page can only be bookmarked once', () async {
await db.addBookmark('https://example.com/', 'Example');
await db.addBookmark('https://example.com/', 'Example again');
expect(await db.allBookmarks(), hasLength(1));
expect(await db.countBookmarks(), 1);
});
test('a site can only be subscribed to once', () async {
await db.addSubscription('https://example.com/', 'Example', null);
await db.addSubscription('https://example.com/', 'Example', null);
expect(await db.allSubscriptions(), hasLength(1));
expect(await db.countSubscriptions(), 1);
});
test('finding a post again keeps its read mark', () async {
const site = 'https://example.com/';
await db.addSubscription(site, 'Example', null);
await db.addEntries(site, [_entry('https://example.com/a.html')]);
expect(await db.unreadCount(), 1);
await db.markEntryRead('https://example.com/a.html');
expect(await db.unreadCount(), 0);
// A refresh sees the same post again; it must not come back unread.
final fresh = await db.addEntries(site, [_entry('https://example.com/a.html')]);
expect(fresh, isEmpty);
expect(await db.unreadCount(), 0);
});
test('unread counts split by site', () async {
await db.addEntries('https://a.example/', [
_entry('https://a.example/one.html'),
_entry('https://a.example/two.html', day: 9),
]);
await db.addEntries('https://b.example/', [_entry('https://b.example/one.html')]);
expect(await db.unreadCount(), 3);
expect(await db.unreadCount(siteUrl: 'https://a.example/'), 2);
expect(await db.unreadCounts(),
{'https://a.example/': 2, 'https://b.example/': 1});
});
test('describing a site leaves the fields it is not given', () async {
const site = 'https://example.com/';
await db.addSubscription(site, 'Signal Bars', null);
// The old single update method blanked the title whenever a refresh only
// had a feed address to record.
await db.describeSubscription(site, atomHref: 'https://example.com/feed.xml');
final row = (await db.allSubscriptions()).single;
expect(row.title, 'Signal Bars');
expect(row.atomHref, 'https://example.com/feed.xml');
});
test('checking a site records the time without touching its posts', () async {
const site = 'https://example.com/';
await db.addSubscription(site, 'Example', null);
await db.addEntries(site, [_entry('https://example.com/a.html')]);
final checkedAt = DateTime(2026, 10, 11, 9, 30);
await db.touchSubscription(site, checkedAt);
expect((await db.allSubscriptions()).single.lastCheckedAt, checkedAt);
expect(await db.unreadCount(), 1);
});
test('history counts addresses rather than visits', () async {
await db.recordVisit('https://example.com/a.html', 'A');
await db.recordVisit('https://example.com/a.html', 'A');
await db.recordVisit('https://example.com/b.html', 'B');
expect(await db.countHistory(), 2);
});
test('an older database is recreated rather than migrated', () async {
final dir = Directory.systemTemp.createTempSync('mews-schema');
addTearDown(() => dir.deleteSync(recursive: true));
final file = File(p.join(dir.path, 'mews.sqlite'));
final before = MewsDatabase.test(NativeDatabase(file));
await before.addBookmark('https://example.com/', 'Example');
await before.addEntries(
'https://example.com/', [_entry('https://example.com/a.html')]);
// Make the file look like the shipped version 1: the posts and tags
// tables did not exist, and the version counter said 1.
await before.customStatement('DROP TABLE feed_entries');
await before.customStatement('DROP TABLE item_tags');
await before.customStatement('PRAGMA user_version = 1');
await before.close();
// Opening it now runs the destructive upgrade: the reader loses their
// local data, which is the agreed trade before release, but the app must
// come up rather than fail on a table that was never there.
final after = MewsDatabase.test(NativeDatabase(file));
addTearDown(after.close);
expect(await after.allBookmarks(), isEmpty);
expect(await after.unreadCount(), 0);
// The new schema works afterwards.
await after.addBookmark('https://example.com/', 'Example');
await after.addEntries(
'https://example.com/', [_entry('https://example.com/a.html')]);
expect(await after.countBookmarks(), 1);
expect(await after.unreadCount(), 1);
});
test('tags follow the item they are on', () async {
const url = 'https://example.com/';
await db.addBookmark(url, 'Example');
await db.addTag(TagKind.bookmark, url, 'phones');
await db.addTag(TagKind.bookmark, url, 'phones');
expect(await db.tagsOf(TagKind.bookmark, url), ['phones']);
// Removing the bookmark takes its tags with it.
await db.removeBookmark(url);
expect(await db.tagsOf(TagKind.bookmark, url), isEmpty);
});
}