feat: store subscription posts with a read mark and split the library controller
This commit is contained in:
parent
b311b9f7dd
commit
953ae798eb
15 changed files with 1787 additions and 386 deletions
35
app/lib/application/controllers/bookmarks_controller.dart
Normal file
35
app/lib/application/controllers/bookmarks_controller.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue