Dr. Doom...scroller titled this one "Scrollbar-ception". Said he "Screenshot of the TikTok webpage."
+
+
An anonymous reader clarified "I was reorganizing the document by drag-and-dropping table cells."
+
+
+
+''';
+
/// Answers every request as if the server had redirected to [finalUrl].
class _RedirectingClient extends http.BaseClient {
_RedirectingClient(this.finalUrl, this.body);
diff --git a/test/comic_reader_test.dart b/test/comic_reader_test.dart
new file mode 100644
index 0000000..81e59be
--- /dev/null
+++ b/test/comic_reader_test.dart
@@ -0,0 +1,109 @@
+import 'dart:io';
+import 'dart:ui' as ui;
+
+import 'package:daily_tech_news/models/article.dart';
+import 'package:daily_tech_news/models/news_source.dart';
+import 'package:daily_tech_news/services/article_extractor.dart';
+import 'package:daily_tech_news/ui/article_screen.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart' as http;
+import 'package:http/testing.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import 'support/fake_app.dart';
+
+/// A plain PNG of the given size, standing in for a comic strip or meme.
+Future> _png(int width, int height) async {
+ final recorder = ui.PictureRecorder();
+ Canvas(recorder).drawRect(
+ Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), Paint()..color = Colors.black);
+ final image = await recorder.endRecording().toImage(width, height);
+ return (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer.asUint8List();
+}
+
+/// Serves a [width] x [height] PNG from this machine, so the reader view can
+/// load and lay out a real picture. Returns its URL.
+Future _serveImage(WidgetTester tester, int width, int height) async {
+ HttpOverrides.global = null;
+ final server = (await tester.runAsync(() => HttpServer.bind(InternetAddress.loopbackIPv4, 0)))!;
+ addTearDown(() => tester.runAsync(server.close));
+ final png = (await tester.runAsync(() => _png(width, height)))!;
+ server.listen((request) => request.response
+ // No idle keep-alive timer left behind in the test's fake time.
+ ..persistentConnection = false
+ ..headers.contentType = ContentType('image', 'png')
+ ..add(png)
+ ..close());
+ return 'http://127.0.0.1:${server.port}/picture.png';
+}
+
+/// Opens [article] in the reader, with the article page answered by [page],
+/// and waits until the picture at [imageUrl] has loaded. Returns its finder.
+Future _openAndLoad(
+ WidgetTester tester, Article article, String imageUrl, http.Response page) async {
+ final state = (await tester.runAsync(() => launchApp([article.source],
+ extractor: ArticleExtractor(client: MockClient((_) async => page)))))!;
+ await tester.pumpWidget(MaterialApp(home: ArticleScreen(state: state, article: article)));
+ final picture = find.descendant(
+ of: find.byWidgetPredicate((w) =>
+ w is Image && w.image is NetworkImage && (w.image as NetworkImage).url == imageUrl),
+ matching: find.byType(RawImage));
+ for (var i = 0; i < 100; i++) {
+ await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20)));
+ await tester.pump();
+ if (picture.evaluate().isNotEmpty && tester.widget(picture).image != null) break;
+ }
+ return picture;
+}
+
+double _aspect(WidgetTester tester, Finder picture) {
+ final size = tester.getSize(picture);
+ return size.height / size.width;
+}
+
+const _titleText = 'The title text is the last joke, hidden in the mouse-over.';
+
+void main() {
+ setUp(() => SharedPreferences.setMockInitialValues({}));
+
+ testWidgets('opening an xkcd comic shows the whole comic and its title text', (tester) async {
+ final comicUrl = await _serveImage(tester, 300, 100);
+ final page = '''
+
+
Strip
+
+
This work is licensed under a Creative Commons Attribution-NonCommercial 2.5 License.
+''';
+ // What the xkcd feed gives: a title, a link and the comic as the image.
+ final article = Article(
+ title: 'Strip',
+ link: 'https://xkcd.com/9999/',
+ source: testSource('xkcd', SourceKind.fun),
+ imageUrl: comicUrl);
+
+ final comic = await _openAndLoad(tester, article, comicUrl,
+ http.Response(page, 200, headers: {'content-type': 'text/html; charset=utf-8'}));
+
+ expect(tester.widget(comic).image, isNotNull, reason: 'the comic loads');
+ expect(_aspect(tester, comic), closeTo(100 / 300, 0.01), reason: 'the comic is not cropped');
+ expect(find.text(_titleText), findsOneWidget);
+ expect(find.textContaining("couldn't be loaded"), findsNothing);
+ });
+
+ testWidgets('a meme whose page blocks the reader still shows the picture from the feed',
+ (tester) async {
+ final memeUrl = await _serveImage(tester, 100, 200);
+ final article = Article(
+ title: 'vibeCoding',
+ link: 'https://www.reddit.com/r/ProgrammerHumor/comments/1/vibecoding/',
+ source: testSource('programmerhumor', SourceKind.fun),
+ imageUrl: memeUrl);
+
+ final meme = await _openAndLoad(tester, article, memeUrl, http.Response('Blocked', 403));
+
+ expect(tester.widget(meme).image, isNotNull, reason: 'the meme loads');
+ expect(_aspect(tester, meme), closeTo(200 / 100, 0.01), reason: 'the meme is not cropped');
+ expect(find.textContaining("couldn't be loaded"), findsNothing);
+ });
+}
diff --git a/test/fun_section_test.dart b/test/fun_section_test.dart
new file mode 100644
index 0000000..e9841a7
--- /dev/null
+++ b/test/fun_section_test.dart
@@ -0,0 +1,102 @@
+import 'package:daily_tech_news/data/default_sources.dart';
+import 'package:daily_tech_news/models/article.dart';
+import 'package:daily_tech_news/models/briefing.dart';
+import 'package:daily_tech_news/models/news_source.dart';
+import 'package:daily_tech_news/services/summarizer.dart';
+import 'package:daily_tech_news/state/app_state.dart';
+import 'package:daily_tech_news/ui/feed_screen.dart';
+import 'package:daily_tech_news/ui/learn_screen.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import 'support/fake_app.dart';
+
+/// Remembers which articles a Briefing was asked for.
+class _RecordingSummarizer implements Summarizer {
+ List? briefingInput;
+
+ @override
+ String get name => 'test';
+
+ @override
+ Future summarizeArticle(Article article, String text, SourceLanguage language) async =>
+ '';
+
+ @override
+ Future createBriefing(List articles, SourceLanguage language) async {
+ briefingInput = articles;
+ return DailyBriefing(createdAt: DateTime.now(), intro: '', topics: const [], generatedBy: name);
+ }
+}
+
+void main() {
+ final verge = testSource('verge', SourceKind.news);
+ final bytebytego = testSource('bytebytego', SourceKind.learning);
+ final xkcd = testSource('xkcd', SourceKind.fun);
+ final feeds = {
+ verge.feedUrl: [('News from this morning', const Duration(hours: 2))],
+ bytebytego.feedUrl: [('Explainer from 2 days ago', const Duration(days: 2))],
+ xkcd.feedUrl: [('Comic from today', const Duration(hours: 5)),
+ ('Comic from 2 days ago', const Duration(days: 2)),
+ ('Comic from 4 days ago', const Duration(days: 4))],
+ };
+
+ setUp(() => SharedPreferences.setMockInitialValues({}));
+
+ Future launch(WidgetTester tester, {Summarizer? summarizer}) async =>
+ (await tester.runAsync(() => launchApp([verge, bytebytego, xkcd],
+ feeds: feeds, summarizer: summarizer)))!;
+
+ double top(WidgetTester tester, String text) => tester.getTopLeft(find.text(text)).dy;
+
+ test('the curated Fun sources are xkcd and The Daily WTF on, r/ProgrammerHumor off', () {
+ final fun = {
+ for (final s in defaultSources)
+ if (s.kind == SourceKind.fun) s.name: (s.feedUrl, s.language, s.enabledByDefault),
+ };
+
+ expect(fun, {
+ 'xkcd': ('https://xkcd.com/atom.xml', SourceLanguage.en, true),
+ 'The Daily WTF': ('https://thedailywtf.com/rss', SourceLanguage.en, true),
+ 'r/ProgrammerHumor': (
+ 'https://www.reddit.com/r/ProgrammerHumor/top/.rss?t=day',
+ SourceLanguage.en,
+ false,
+ ),
+ });
+ });
+
+ testWidgets('the Learn tab has a Fun section below Learning with the last 3 days of Fun',
+ (tester) async {
+ final state = await launch(tester);
+
+ await tester.pumpWidget(MaterialApp(home: LearnScreen(state: state)));
+
+ expect(find.text('Fun'), findsOneWidget);
+ expect(find.text('2 articles · last 3 days'), findsOneWidget);
+ expect(top(tester, 'Learning'), lessThan(top(tester, 'Fun')));
+ expect(top(tester, 'Explainer from 2 days ago'), lessThan(top(tester, 'Fun')));
+ expect(top(tester, 'Fun'), lessThan(top(tester, 'Comic from today')));
+ expect(top(tester, 'Comic from today'), lessThan(top(tester, 'Comic from 2 days ago')));
+ expect(find.text('Comic from 4 days ago'), findsNothing);
+ });
+
+ testWidgets('Today leaves out Fun articles', (tester) async {
+ final state = await launch(tester);
+
+ await tester.pumpWidget(MaterialApp(home: FeedScreen(state: state)));
+
+ expect(find.text('News from this morning'), findsOneWidget);
+ expect(find.text('Comic from today'), findsNothing);
+ });
+
+ testWidgets('the Briefing leaves out Fun articles', (tester) async {
+ final summarizer = _RecordingSummarizer();
+ final state = await launch(tester, summarizer: summarizer);
+
+ await tester.runAsync(state.generateBriefing);
+
+ expect(summarizer.briefingInput!.map((a) => a.title), ['News from this morning']);
+ });
+}
diff --git a/test/source_selection_test.dart b/test/source_selection_test.dart
index 7be7764..cf681c1 100644
--- a/test/source_selection_test.dart
+++ b/test/source_selection_test.dart
@@ -88,6 +88,7 @@ void main() {
'heise', 'verge', 'mit-tr', 'new-source',
// Added to the catalog later, on by default.
'techmeme', 'lobsters', 'the-decoder', 'simonwillison', 'bytebytego',
+ 'xkcd', 'thedailywtf',
});
});
@@ -103,6 +104,7 @@ void main() {
expect(state.enabledSourceIds, {
'heise', 'golem', 't3n', 'verge',
'techmeme', 'lobsters', 'the-decoder', 'simonwillison', 'bytebytego',
+ 'xkcd', 'thedailywtf',
});
expect(state.enabledSources.map((s) => s.id), isNot(contains('wired')));
});
diff --git a/test/support/fake_app.dart b/test/support/fake_app.dart
index 61c3279..60b7769 100644
--- a/test/support/fake_app.dart
+++ b/test/support/fake_app.dart
@@ -1,5 +1,6 @@
import 'package:daily_tech_news/models/article.dart';
import 'package:daily_tech_news/models/news_source.dart';
+import 'package:daily_tech_news/services/article_extractor.dart';
import 'package:daily_tech_news/services/feed_service.dart';
import 'package:daily_tech_news/services/storage.dart';
import 'package:daily_tech_news/services/summarizer.dart';
@@ -58,11 +59,13 @@ Future launchApp(
Map> feeds = const {},
FeedService? feedService,
Summarizer? summarizer,
+ ArticleExtractor? extractor,
}) async {
final state = AppState(await Storage.open(),
feedService: feedService ?? FakeFeeds(feeds),
curatedSources: curatedSources,
- summarizer: summarizer);
+ summarizer: summarizer,
+ extractor: extractor);
await state.init();
return state;
}
--
2.54.0
From 84cb11e4dd80076294b6bad7a6f7c0864de579c7 Mon Sep 17 00:00:00 2001
From: Cedric Hornberger
Date: Fri, 16 Oct 2026 15:57:48 +0200
Subject: [PATCH 08/14] Hide paid Substack newsletter posts with the paid
filter (#7)
Substack cuts paid-subscriber posts short in the feed and ends their
content with a "Read more" link to the post itself. PaywallService.isPaid
now recognizes that ending, so "hide paid articles" hides these posts in
Today and the Learn tab and counts them as hidden, without a page request.
Free posts that ask readers to subscribe or end with the subscribe widget
are not affected.
Tests use trimmed real items from the Interconnects and Ahead of AI feeds.
Co-Authored-By: Claude Opus 5.5
---
README.md | 4 +-
lib/services/paywall_service.dart | 16 +++++-
test/fixtures/ahead_of_ai_feed.xml | 15 ++++++
test/fixtures/interconnects_feed.xml | 13 +++++
test/paid_newsletter_posts_test.dart | 75 ++++++++++++++++++++++++++++
test/paywall_service_test.dart | 18 +++++++
6 files changed, 139 insertions(+), 2 deletions(-)
create mode 100644 test/fixtures/ahead_of_ai_feed.xml
create mode 100644 test/fixtures/interconnects_feed.xml
create mode 100644 test/paid_newsletter_posts_test.dart
diff --git a/README.md b/README.md
index 2b4b972..187a45a 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,9 @@ or write a daily briefing. It's meant to replace the n8n → Discord workflow.
- **Paid articles and ads hidden**: heise+ and Golem `(g+)` are recognized by
their title; SPIEGEL+ and t3n Pro by a marker in the article page, which the app
checks once per new article in the background (it reads only until the marker,
- and remembers results for 14 days). Sponsored posts (`Anzeige:`) are hidden too.
+ and remembers results for 14 days). Paid-subscriber posts from Substack newsletters
+ (Interconnects, Ahead of AI, …) are recognized in the feed itself, where Substack
+ cuts them off with a "Read more" link. Sponsored posts (`Anzeige:`) are hidden too.
The feed header shows how many were hidden; both filters can be turned off.
- **Ad and cookie-banner blocking** in the original-page view: requests to ~80
ad/tracker hosts and the common consent managers (Sourcepoint, OneTrust,
diff --git a/lib/services/paywall_service.dart b/lib/services/paywall_service.dart
index c4d9e7d..fa0e1f0 100644
--- a/lib/services/paywall_service.dart
+++ b/lib/services/paywall_service.dart
@@ -9,6 +9,8 @@ import 'storage.dart';
/// Recognizes paid and sponsored articles so the feed can hide them.
///
/// Most paid articles are marked in the title (heise+, Golem's "(g+)").
+/// Substack newsletters cut paid posts short in the feed and end them with a
+/// "Read more" link to the post itself, so those are recognized from the feed.
/// SPIEGEL and t3n only mark them in the page's schema.org data
/// (`"isAccessibleForFree": false`), so for those sources each new article is
/// checked once in the background and the result is remembered.
@@ -33,6 +35,14 @@ class PaywallService {
caseSensitive: false,
);
+ /// What Substack appends to a paid post's feed content in place of the rest:
+ /// `
\s*$',
+ caseSensitive: false,
+ );
+
static final _marker =
RegExp(r'''"isAccessibleForFree"\s*:\s*"?(true|false)"?''', caseSensitive: false);
@@ -41,7 +51,11 @@ class PaywallService {
static const _concurrency = 6;
static const _keepFor = Duration(days: 14);
- bool isPaid(Article a) => paidTitle.hasMatch(a.title) || (_checks[a.link]?.paid ?? false);
+ bool isPaid(Article a) =>
+ paidTitle.hasMatch(a.title) || _isCutSubstackPost(a) || (_checks[a.link]?.paid ?? false);
+
+ static bool _isCutSubstackPost(Article a) =>
+ _substackCut.firstMatch(a.contentHtml)?.group(1) == a.link;
bool isSponsored(Article a) => sponsoredTitle.hasMatch(a.title);
diff --git a/test/fixtures/ahead_of_ai_feed.xml b/test/fixtures/ahead_of_ai_feed.xml
new file mode 100644
index 0000000..deee5a3
--- /dev/null
+++ b/test/fixtures/ahead_of_ai_feed.xml
@@ -0,0 +1,15 @@
+https://magazine.sebastianraschka.comSubstack
+
+
+https://magazine.sebastianraschka.com/p/ai-detector-from-scratchhttps://magazine.sebastianraschka.com/p/ai-detector-from-scratchSat, 15 Aug 2026 11:54:24 GMTSubstack recently launched its AI detector feature in the UI, which is super interesting.
For this, we are going to fine-tune a DistilBERT classifier (similar to what I described in one of my early Substack articles, Finetuning Large Language Models), but more details on that later when we get to that stage.
+ ]]>
+
+https://magazine.sebastianraschka.com/p/controlling-reasoning-effort-in-llmshttps://magazine.sebastianraschka.com/p/controlling-reasoning-effort-in-llmsSat, 18 Jul 2026 11:16:09 GMTIt has been almost two years since OpenAI released o1, a model that popularized the idea of LLM-based reasoning models. DeepSeek-R1 followed about four months later, together with details of a reinforcement learning with verifiable rewards (RLVR) recipe to train such reasoning models.
If you liked my previous Build a Large Language Model (From Scratch) book, this is essentially a sequel implementing inference-time scaling techniques and reinforcement learning algorithms from scratch.
And if you want to support future long-form articles like this one, consider becoming a paid subscriber. It helps me keep writing these independent deep dives and sharing the accompanying code, figures, and experiments.
]]>
+
+https://magazine.sebastianraschka.com/p/gpt-6-astra-looped-transformers-andhttps://magazine.sebastianraschka.com/p/gpt-6-astra-looped-transformers-andWed, 09 Sep 2026 11:14:25 GMTA lot has happened in the last few weeks. I am sure that OpenAI’s GPT-6 Astra is top of mind for everyone right now. In particular, thoughts on its performance, the looped transformer/recurrent depth aspects, and rumors that Astra is “hiding” its reasoning trace (i.e., chain of thought).
]]>
+
diff --git a/test/fixtures/interconnects_feed.xml b/test/fixtures/interconnects_feed.xml
new file mode 100644
index 0000000..a161bfb
--- /dev/null
+++ b/test/fixtures/interconnects_feed.xml
@@ -0,0 +1,13 @@
+https://www.interconnects.aiSubstack
+
+
+https://www.interconnects.ai/p/lessons-from-the-hackshttps://www.interconnects.ai/p/lessons-from-the-hacksSun, 09 Aug 2026 14:57:11 GMTThe recent run of cyberattacks by in-development frontier models has got me thinking a lot about how our current incentive systems are not well suited for such fast technological transitions.
Book sale
I’m also hosting a book launch where you can get a free signed copy tomorrow from 5-8PM in Seattle (Fremont/Ballard area) – we still have some extra space so I’m opening signups to paid subscribers below the paywall:
+ ]]>
+
+https://www.interconnects.ai/p/when-will-average-people-feel-aishttps://www.interconnects.ai/p/when-will-average-people-feel-aisWed, 09 Sep 2026 11:01:23 GMTHousekeeping: Paid subscribers to Interconnects now get a permanent 40% discount on my book when purchasing at Manning.com. Access the code at the Interconnects perks page.
Many AI optimists tend to compare what is happening in this AI boom to the industrial revolution, or to other periods of rapid technological advancement and diffusion into society.
',
+ );
+ const post = 'https://example.substack.com/p/a-paid-post';
+
+ expect(paywall.isPaid(cutPost(link: post, readMore: '$post/')), isTrue,
+ reason: 'trailing slash on the "Read more" link');
+ expect(paywall.isPaid(cutPost(link: '$post/', readMore: post)), isTrue,
+ reason: 'trailing slash on the article link');
+ expect(paywall.isPaid(cutPost(link: post, readMore: '$post?utm_source=substack&utm_medium=email')),
+ isTrue, reason: 'tracking query on the "Read more" link');
+ expect(paywall.isPaid(cutPost(link: '$post?utm_campaign=post#footnote-1', readMore: post)), isTrue,
+ reason: 'query and fragment on the article link');
+ expect(paywall.isPaid(cutPost(link: post, readMore: 'http://Example.Substack.com/p/a-paid-post')),
+ isTrue, reason: 'other scheme and host case');
+ expect(paywall.isPaid(cutPost(link: post, readMore: 'https://example.substack.com/p/another-post')),
+ isFalse, reason: 'a "Read more" link to a different post');
+ });
+
test('checks pages only for flagged sources and remembers results', () async {
final storage = await _storage();
final requested = [];
--
2.54.0
From 246564f1d9f52f2c362662922ff43551c1cb98ab Mon Sep 17 00:00:00 2001
From: Cedric Hornberger
Date: Fri, 16 Oct 2026 16:07:37 +0200
Subject: [PATCH 11/14] Keep per-kind facts on SourceKind and the Briefing's
input in one place (review items 4, 5, 6, 7)
- SourceKind carries whether it feeds the Briefing, its fixed time window
(Learning, Fun) and whether reading is picture-led (Fun); windowOf and
the reader read from it.
- AppState.briefingArticles is the one place that says only News sources
feed the Briefing; generateBriefing and the Briefing tab use it.
- articlesOf, hiddenCountOf and briefingArticles share one filter.
- FeedService.fetchAll takes windowOf: like AppState.windowOf.
Co-Authored-By: Claude Opus 5.5
---
lib/models/source.dart | 23 ++++++++++++++++++----
lib/services/feed_service.dart | 8 ++++----
lib/state/app_state.dart | 29 ++++++++++++++++------------
lib/ui/article_screen.dart | 5 +----
lib/ui/briefing_screen.dart | 5 ++---
test/source_kinds_test.dart | 34 +++++++++++++++++++++++++++++++++
test/source_selection_test.dart | 2 +-
test/support/fake_app.dart | 4 ++--
8 files changed, 80 insertions(+), 30 deletions(-)
diff --git a/lib/models/source.dart b/lib/models/source.dart
index b55d28e..f62ad78 100644
--- a/lib/models/source.dart
+++ b/lib/models/source.dart
@@ -11,13 +11,28 @@ enum SourceLanguage {
/// What a Source's articles are for; see GLOSSARY.md. Each kind has its own
/// section in the app and its own time window.
enum SourceKind {
- news('News'),
- learning('Learning'),
- fun('Fun');
+ news('News', feedsBriefing: true),
+ learning('Learning', fixedWindow: Duration(days: 14)),
+ fun('Fun', fixedWindow: Duration(days: 3), pictureLed: true);
- const SourceKind(this.label);
+ const SourceKind(
+ this.label, {
+ this.feedsBriefing = false,
+ this.fixedWindow,
+ this.pictureLed = false,
+ });
final String label;
+
+ /// Only News sources feed the Briefing.
+ final bool feedsBriefing;
+
+ /// How far back articles reach; null where the user sets it (News).
+ final Duration? fixedWindow;
+
+ /// Comics and memes are the picture: the reader shows it whole in the body
+ /// rather than as a cropped banner.
+ final bool pictureLed;
}
/// A Source of any [kind]; see GLOSSARY.md.
diff --git a/lib/services/feed_service.dart b/lib/services/feed_service.dart
index 5dce82e..e3af97c 100644
--- a/lib/services/feed_service.dart
+++ b/lib/services/feed_service.dart
@@ -23,15 +23,15 @@ class FeedService {
static const _timeout = Duration(seconds: 20);
- /// Fetches all [sources] in parallel and drops articles older than the age
- /// [maxAgeOf] gives for their Source. Failing feeds are reported in
+ /// Fetches all [sources] in parallel and drops articles older than the time
+ /// window [windowOf] gives for their Source. Failing feeds are reported in
/// [FeedResult.errors] instead of failing the whole refresh.
///
/// [corsProxy] is only used on web, where browsers block cross-origin feed
/// requests. It is prepended to the feed URL, e.g. `https://proxy/?url=`.
Future fetchAll(
List sources, {
- required Duration Function(Source source) maxAgeOf,
+ required Duration Function(Source source) windowOf,
String corsProxy = '',
}) async {
final errors = {};
@@ -50,7 +50,7 @@ class FeedService {
for (final (i, list) in results.indexed)
for (final a in list)
if ((a.published == null ||
- a.published!.isAfter(now.subtract(maxAgeOf(sources[i])))) &&
+ a.published!.isAfter(now.subtract(windowOf(sources[i])))) &&
seen.add(a.id))
a,
];
diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart
index 305888e..111597a 100644
--- a/lib/state/app_state.dart
+++ b/lib/state/app_state.dart
@@ -82,22 +82,27 @@ class AppState extends ChangeNotifier {
(settings.hidePaid && paywall.isPaid(a)) ||
(settings.hideSponsored && paywall.isSponsored(a));
+ /// Articles from Sources whose kind passes [test] that the paid/sponsored
+ /// filters show, or with [hidden], those they hide.
+ Iterable _articlesWhere(bool Function(SourceKind kind) test,
+ {bool hidden = false}) =>
+ articles.where((a) => test(a.source.kind) && _isHidden(a) == hidden);
+
/// Articles from Sources of [kind] after the paid/sponsored filters. Today
- /// and the Briefing use News, the Learn tab the other kinds.
- List articlesOf(SourceKind kind) =>
- articles.where((a) => a.source.kind == kind && !_isHidden(a)).toList();
+ /// uses News, the Learn tab the other kinds.
+ List articlesOf(SourceKind kind) => _articlesWhere((k) => k == kind).toList();
/// Articles from Sources of [kind] that the paid/sponsored filters hide.
- int hiddenCountOf(SourceKind kind) =>
- articles.where((a) => a.source.kind == kind && _isHidden(a)).length;
+ int hiddenCountOf(SourceKind kind) => _articlesWhere((k) => k == kind, hidden: true).length;
+
+ /// What the Briefing is made from: the shown articles of the kinds that
+ /// feed it (News).
+ List get briefingArticles => _articlesWhere((k) => k.feedsBriefing).toList();
/// How far back articles from Sources of [kind] reach. News follows the
/// user's setting; the other kinds age slower.
- Duration windowOf(SourceKind kind) => switch (kind) {
- SourceKind.news => Duration(hours: settings.maxAgeHours),
- SourceKind.learning => const Duration(days: 14),
- SourceKind.fun => const Duration(days: 3),
- };
+ Duration windowOf(SourceKind kind) =>
+ kind.fixedWindow ?? Duration(hours: settings.maxAgeHours);
List get allSources => [..._curated, ...customSources];
@@ -119,7 +124,7 @@ class AppState extends ChangeNotifier {
notifyListeners();
final result = await _feeds.fetchAll(
enabledSources,
- maxAgeOf: (source) => windowOf(source.kind),
+ windowOf: (source) => windowOf(source.kind),
corsProxy: settings.corsProxy,
);
articles = result.articles;
@@ -292,7 +297,7 @@ class AppState extends ChangeNotifier {
Future generateBriefing() async {
final summarizer = _summarizer;
if (summarizer == null || briefingLoading) return;
- final articles = articlesOf(SourceKind.news);
+ final articles = briefingArticles;
if (articles.isEmpty) {
briefingError = 'No articles loaded yet. Pull to refresh the feed first.';
notifyListeners();
diff --git a/lib/ui/article_screen.dart b/lib/ui/article_screen.dart
index 3bb2079..1dc5709 100644
--- a/lib/ui/article_screen.dart
+++ b/lib/ui/article_screen.dart
@@ -6,7 +6,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../models/article.dart';
-import '../models/source.dart';
import '../services/article_extractor.dart';
import '../services/consent_service.dart';
import '../services/content_blocking.dart';
@@ -276,9 +275,7 @@ class _ReaderView extends StatelessWidget {
builder: (context, snapshot) {
final loaded = snapshot.data;
var blocks = loaded?.blocks ?? const [];
- // Comics and memes are the picture: show it whole in the body
- // rather than as a cropped banner.
- final pictureLed = article.source.kind == SourceKind.fun;
+ final pictureLed = article.source.kind.pictureLed;
final banner = pictureLed ? null : article.imageUrl;
// The page's own hero image duplicates the feed image shown on top.
if (banner != null) {
diff --git a/lib/ui/briefing_screen.dart b/lib/ui/briefing_screen.dart
index b8a69d6..32fa414 100644
--- a/lib/ui/briefing_screen.dart
+++ b/lib/ui/briefing_screen.dart
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import '../models/briefing.dart';
-import '../models/source.dart';
import '../state/app_state.dart';
import 'article_screen.dart';
import 'briefing_history_screen.dart';
@@ -60,7 +59,7 @@ class BriefingScreen extends StatelessWidget {
if (state.briefingLoading) {
return EmptyState(
icon: Icons.hourglass_top,
- title: 'Reading ${state.articlesOf(SourceKind.news).length} headlines…',
+ title: 'Reading ${state.briefingArticles.length} headlines…',
message: 'This usually takes 10-60 seconds.',
action: const SizedBox(width: 160, child: LinearProgressIndicator()),
);
@@ -70,7 +69,7 @@ class BriefingScreen extends StatelessWidget {
icon: const Icon(Icons.auto_awesome),
label: Text(state.loading
? 'Waiting for feeds…'
- : 'Generate from ${state.articlesOf(SourceKind.news).length} articles'),
+ : 'Generate from ${state.briefingArticles.length} articles'),
);
if (briefing == null) {
return EmptyState(
diff --git a/test/source_kinds_test.dart b/test/source_kinds_test.dart
index bb49562..352912a 100644
--- a/test/source_kinds_test.dart
+++ b/test/source_kinds_test.dart
@@ -1,10 +1,13 @@
import 'dart:convert';
import 'package:daily_tech_news/data/default_sources.dart';
+import 'package:daily_tech_news/models/app_settings.dart';
import 'package:daily_tech_news/models/article.dart';
import 'package:daily_tech_news/models/briefing.dart';
import 'package:daily_tech_news/models/source.dart';
import 'package:daily_tech_news/services/summarizer.dart';
+import 'package:daily_tech_news/ui/briefing_screen.dart';
+import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -62,6 +65,37 @@ void main() {
expect(_titles(summarizer.briefingInput!), ['News from this morning']);
});
+ test('the Briefing reads the shown News articles, not Learning, Fun or hidden ones', () async {
+ final xkcd = testSource('xkcd', SourceKind.fun);
+ final state = await launchApp([verge, bytebytego, xkcd], feeds: {
+ verge.feedUrl: [('News from this morning', const Duration(hours: 2)),
+ ('Sponsored: Buy our phone', const Duration(hours: 3))],
+ bytebytego.feedUrl: [('Explainer from today', const Duration(hours: 1))],
+ xkcd.feedUrl: [('Comic from today', const Duration(hours: 1))],
+ });
+
+ expect(_titles(state.briefingArticles), ['News from this morning']);
+ });
+
+ testWidgets('the Briefing tab counts only the articles the Briefing is made from',
+ (tester) async {
+ final state = (await tester.runAsync(() async {
+ final state = await launchApp([verge, bytebytego], feeds: {
+ verge.feedUrl: [('News from this morning', const Duration(hours: 2))],
+ bytebytego.feedUrl: [('Explainer from today', const Duration(hours: 1)),
+ ('Explainer from yesterday', const Duration(days: 1))],
+ });
+ await state.updateSettings(
+ state.settings.copyWith(aiProvider: AiProvider.claude, claudeApiKey: 'key'));
+ return state;
+ }))!;
+
+ await tester.pumpWidget(
+ MaterialApp(home: BriefingScreen(state: state, openSettings: () {})));
+
+ expect(find.text('Generate from 1 articles'), findsOneWidget);
+ });
+
test('News follows the window setting', () async {
SharedPreferences.setMockInitialValues({'max_age_hours': 48});
final state = await launchApp([verge], feeds: {
diff --git a/test/source_selection_test.dart b/test/source_selection_test.dart
index 8578bd0..f2027e1 100644
--- a/test/source_selection_test.dart
+++ b/test/source_selection_test.dart
@@ -10,7 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
class _NoFeeds extends FeedService {
@override
Future fetchAll(List sources,
- {required Duration Function(Source source) maxAgeOf, String corsProxy = ''}) async =>
+ {required Duration Function(Source source) windowOf, String corsProxy = ''}) async =>
const FeedResult([], {});
}
diff --git a/test/support/fake_app.dart b/test/support/fake_app.dart
index 4b659f3..6fe0d65 100644
--- a/test/support/fake_app.dart
+++ b/test/support/fake_app.dart
@@ -19,9 +19,9 @@ class FakeFeeds extends FeedService {
@override
Future fetchAll(List sources,
- {required Duration Function(Source source) maxAgeOf, String corsProxy = ''}) {
+ {required Duration Function(Source source) windowOf, String corsProxy = ''}) {
refreshes++;
- return super.fetchAll(sources, maxAgeOf: maxAgeOf, corsProxy: corsProxy);
+ return super.fetchAll(sources, windowOf: windowOf, corsProxy: corsProxy);
}
@override
--
2.54.0
From b773c363fe30754750ef37f453a4f9e9aa1ff8d3 Mon Sep 17 00:00:00 2001
From: Cedric Hornberger
Date: Fri, 16 Oct 2026 16:07:37 +0200
Subject: [PATCH 12/14] Share the article count line and refresh scaffolding of
Today and Learn (review items 5, 6)
articleCountLine, RefreshButton and RefreshableList live in common.dart.
LearnScreen._describe becomes _windowLabel there, and LearnScreen._section
is _kindSection like in SourcesScreen.
Co-Authored-By: Claude Opus 5.5
---
lib/ui/common.dart | 47 ++++++++++++++++++++++++++++++++++++++++
lib/ui/feed_screen.dart | 32 +++++++--------------------
lib/ui/learn_screen.dart | 37 +++++++++----------------------
3 files changed, 65 insertions(+), 51 deletions(-)
diff --git a/lib/ui/common.dart b/lib/ui/common.dart
index 1cc0669..e15a1a9 100644
--- a/lib/ui/common.dart
+++ b/lib/ui/common.dart
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
+import '../models/source.dart';
+import '../state/app_state.dart';
+
/// User-facing app name. Platform launcher labels (AndroidManifest, Info.plist,
/// web/manifest.json, windows/runner) are set separately.
const appName = 'Tagesbyte';
@@ -33,6 +36,17 @@ const monthNames = [
bool isSameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
+/// The line above the articles of [kind], e.g.
+/// "12 articles · last 24 h · 3 paid/ads hidden".
+String articleCountLine(AppState state, SourceKind kind) {
+ final hidden = state.hiddenCountOf(kind);
+ return '${state.articlesOf(kind).length} articles · last ${_windowLabel(state.windowOf(kind))}'
+ '${hidden > 0 ? ' · $hidden paid/ads hidden' : ''}';
+}
+
+String _windowLabel(Duration window) =>
+ window.inHours < 72 ? '${window.inHours} h' : '${window.inDays} days';
+
Future openUrl(BuildContext context, String url) async {
final ok = await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
if (!ok && context.mounted) {
@@ -41,6 +55,39 @@ Future openUrl(BuildContext context, String url) async {
}
}
+/// App bar button that refreshes all Sources; off while a refresh runs.
+class RefreshButton extends StatelessWidget {
+ const RefreshButton({super.key, required this.state});
+
+ final AppState state;
+
+ @override
+ Widget build(BuildContext context) => IconButton(
+ tooltip: 'Refresh',
+ onPressed: state.loading ? null : state.refresh,
+ icon: const Icon(Icons.refresh),
+ );
+}
+
+/// A scrollable [list] that refreshes all Sources when pulled down, under a
+/// progress bar while a refresh runs and an optional [notice].
+class RefreshableList extends StatelessWidget {
+ const RefreshableList({super.key, required this.state, required this.list, this.notice});
+
+ final AppState state;
+ final Widget list;
+ final Widget? notice;
+
+ @override
+ Widget build(BuildContext context) => Column(
+ children: [
+ if (state.loading) const LinearProgressIndicator(),
+ if (notice != null) notice!,
+ Expanded(child: RefreshIndicator(onRefresh: state.refresh, child: list)),
+ ],
+ );
+}
+
/// Centered icon + text + optional action, used for empty and error states.
class EmptyState extends StatelessWidget {
const EmptyState({
diff --git a/lib/ui/feed_screen.dart b/lib/ui/feed_screen.dart
index e0aeca5..058fa4d 100644
--- a/lib/ui/feed_screen.dart
+++ b/lib/ui/feed_screen.dart
@@ -31,7 +31,6 @@ class _FeedScreenState extends State {
.where((a) => _sourceId == null || a.source.id == _sourceId)
.toList();
final sourcesInFeed = {for (final a in all) a.source.id: a.source};
- final hidden = state.hiddenCountOf(SourceKind.news);
return Scaffold(
appBar: AppBar(
@@ -40,38 +39,23 @@ class _FeedScreenState extends State {
children: [
const Text(appName),
Text(
- state.loading
- ? 'Loading feeds…'
- : '${all.length} articles · last ${state.settings.maxAgeHours} h'
- '${hidden > 0 ? ' · $hidden paid/ads hidden' : ''}',
+ state.loading ? 'Loading feeds…' : articleCountLine(state, SourceKind.news),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
- actions: [
- IconButton(
- tooltip: 'Refresh',
- onPressed: state.loading ? null : state.refresh,
- icon: const Icon(Icons.refresh),
- ),
- ],
+ actions: [RefreshButton(state: state)],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(52),
child: _filterBar(sourcesInFeed.values.toList()),
),
),
- body: Column(
- children: [
- if (state.loading) const LinearProgressIndicator(),
- if (state.feedErrors.isNotEmpty && !state.loading)
- _ErrorBanner(errors: state.feedErrors),
- Expanded(
- child: RefreshIndicator(
- onRefresh: state.refresh,
- child: _list(state, visible),
- ),
- ),
- ],
+ body: RefreshableList(
+ state: state,
+ notice: state.feedErrors.isNotEmpty && !state.loading
+ ? _ErrorBanner(errors: state.feedErrors)
+ : null,
+ list: _list(state, visible),
),
);
},
diff --git a/lib/ui/learn_screen.dart b/lib/ui/learn_screen.dart
index e834bf7..3f7026b 100644
--- a/lib/ui/learn_screen.dart
+++ b/lib/ui/learn_screen.dart
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../models/source.dart';
import '../state/app_state.dart';
import 'article_screen.dart';
+import 'common.dart';
import 'feed_screen.dart';
/// Reading that keeps for days, from the Sources that aren't News: one
@@ -22,37 +23,23 @@ class LearnScreen extends StatelessWidget {
builder: (context, _) => Scaffold(
appBar: AppBar(
title: const Text('Learn'),
- actions: [
- IconButton(
- tooltip: 'Refresh',
- onPressed: state.loading ? null : state.refresh,
- icon: const Icon(Icons.refresh),
- ),
- ],
+ actions: [RefreshButton(state: state)],
),
- body: Column(
- children: [
- if (state.loading) const LinearProgressIndicator(),
- Expanded(
- child: RefreshIndicator(
- onRefresh: state.refresh,
- child: ListView(
- padding: const EdgeInsets.fromLTRB(12, 0, 12, 24),
- children: [for (final kind in kinds) ..._section(context, kind)],
- ),
- ),
- ),
- ],
+ body: RefreshableList(
+ state: state,
+ list: ListView(
+ padding: const EdgeInsets.fromLTRB(12, 0, 12, 24),
+ children: [for (final kind in kinds) ..._kindSection(context, kind)],
+ ),
),
),
);
}
- List _section(BuildContext context, SourceKind kind) {
+ List _kindSection(BuildContext context, SourceKind kind) {
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
final articles = state.articlesOf(kind);
- final hidden = state.hiddenCountOf(kind);
final hasSources = state.enabledSources.any((s) => s.kind == kind);
return [
Padding(
@@ -63,8 +50,7 @@ class LearnScreen extends StatelessWidget {
Text(kind.label,
style: theme.textTheme.titleMedium?.copyWith(color: theme.colorScheme.primary)),
Text(
- '${articles.length} articles · last ${_describe(state.windowOf(kind))}'
- '${hidden > 0 ? ' · $hidden paid/ads hidden' : ''}',
+ articleCountLine(state, kind),
style: theme.textTheme.bodySmall?.copyWith(color: muted),
),
],
@@ -92,7 +78,4 @@ class LearnScreen extends StatelessWidget {
),
];
}
-
- static String _describe(Duration window) =>
- window.inHours < 72 ? '${window.inHours} h' : '${window.inDays} days';
}
--
2.54.0
From 130c5e6bb7d6dfcf9ce42f2d85b3f9c478c27369 Mon Sep 17 00:00:00 2001
From: Cedric Hornberger
Date: Fri, 16 Oct 2026 16:09:43 +0200
Subject: [PATCH 13/14] Use the glossary's words in test names and comments
(review item 2)
"Feed" no longer stands for a Source and "newsletter" no longer for a
Learning source; paid_newsletter_posts_test.dart becomes
paid_substack_posts_test.dart.
Co-Authored-By: Claude Opus 5.5
---
lib/services/paywall_service.dart | 2 +-
test/learn_tab_test.dart | 2 +-
...test.dart => paid_substack_posts_test.dart} | 4 ++--
test/sources_tab_test.dart | 18 +++++++++---------
test/support/fake_app.dart | 2 +-
5 files changed, 14 insertions(+), 14 deletions(-)
rename test/{paid_newsletter_posts_test.dart => paid_substack_posts_test.dart} (93%)
diff --git a/lib/services/paywall_service.dart b/lib/services/paywall_service.dart
index 93c0ba0..0eadb8e 100644
--- a/lib/services/paywall_service.dart
+++ b/lib/services/paywall_service.dart
@@ -9,7 +9,7 @@ import 'storage.dart';
/// Recognizes paid and sponsored articles so the feed can hide them.
///
/// Most paid articles are marked in the title (heise+, Golem's "(g+)").
-/// Substack newsletters cut paid posts short in the feed and end them with a
+/// Substack cuts paid posts short in the feed and ends them with a
/// "Read more" link to the post itself, so those are recognized from the feed.
/// SPIEGEL and t3n only mark them in the page's schema.org data
/// (`"isAccessibleForFree": false`), so for those sources each new article is
diff --git a/test/learn_tab_test.dart b/test/learn_tab_test.dart
index 0a78b90..690cf4a 100644
--- a/test/learn_tab_test.dart
+++ b/test/learn_tab_test.dart
@@ -73,7 +73,7 @@ void main() {
expect(find.byType(SettingsScreen), findsOneWidget);
});
- testWidgets('leaving the Sources tab after a change refreshes the feeds', (tester) async {
+ testWidgets('leaving the Sources tab after a change refreshes all Sources', (tester) async {
final feedService = FakeFeeds(feeds);
final state = await launch(tester, feedService);
await tester.pumpWidget(MaterialApp(home: HomeShell(state: state)));
diff --git a/test/paid_newsletter_posts_test.dart b/test/paid_substack_posts_test.dart
similarity index 93%
rename from test/paid_newsletter_posts_test.dart
rename to test/paid_substack_posts_test.dart
index 66c5e55..b6f6923 100644
--- a/test/paid_newsletter_posts_test.dart
+++ b/test/paid_substack_posts_test.dart
@@ -47,7 +47,7 @@ void main() {
substackNews.feedUrl: 'test/fixtures/ahead_of_ai_feed.xml',
});
- test('paid newsletter posts are hidden in the Learn tab and Today and counted as hidden',
+ test('paid Substack posts are hidden in the Learn tab and Today and counted as hidden',
() async {
final state = await launchApp([interconnects, substackNews], feedService: savedFeeds());
@@ -61,7 +61,7 @@ void main() {
expect(state.hiddenCountOf(SourceKind.news), 1);
});
- test('with "hide paid articles" off, paid newsletter posts show as before', () async {
+ test('with "hide paid articles" off, paid Substack posts show as before', () async {
final state = await launchApp([interconnects, substackNews], feedService: savedFeeds());
await state.updateSettings(state.settings.copyWith(hidePaid: false));
diff --git a/test/sources_tab_test.dart b/test/sources_tab_test.dart
index 67dbf36..0c0c1da 100644
--- a/test/sources_tab_test.dart
+++ b/test/sources_tab_test.dart
@@ -80,7 +80,7 @@ void main() {
expect(find.text(german), findsNothing);
});
- Future addFeed(WidgetTester tester, String name, {String? kind}) async {
+ Future addSource(WidgetTester tester, String name, {String? kind}) async {
await tester.tap(find.text('Add feed'));
await tester.pumpAndSettle();
final dialog = find.byType(AlertDialog);
@@ -97,29 +97,29 @@ void main() {
await tester.pumpAndSettle();
}
- testWidgets('a feed added without picking a kind is a News source', (tester) async {
+ testWidgets('a Source added without picking a kind is a News source', (tester) async {
final state = await launch(tester, [verge, bytebytego]);
await showSources(tester, state);
- await addFeed(tester, 'myfeed');
+ await addSource(tester, 'myfeed');
expectInOrder(tester, [find.text('News'), find.text('myfeed'), find.text('Learning')]);
});
- testWidgets('a feed added as Learning shows up in the Learning section', (tester) async {
+ testWidgets('a Source added as Learning shows up in the Learning section', (tester) async {
final state = await launch(tester, [verge, bytebytego]);
await showSources(tester, state);
- await addFeed(tester, 'myblog', kind: 'Learning');
+ await addSource(tester, 'myblog', kind: 'Learning');
expectInOrder(tester, [find.text('Learning'), find.text('myblog')]);
});
- testWidgets('a feed added as Fun gets a Fun section', (tester) async {
+ testWidgets('a Source added as Fun gets a Fun section', (tester) async {
final state = await launch(tester, [verge]);
await showSources(tester, state);
- await addFeed(tester, 'mycomic', kind: 'Fun');
+ await addSource(tester, 'mycomic', kind: 'Fun');
expectInOrder(tester, [find.text('News'), find.text('Fun'), find.text('mycomic')]);
});
@@ -140,7 +140,7 @@ void main() {
await tester.tap(tab('Sources'));
await tester.pump();
- await addFeed(tester, 'myblog', kind: 'Learning');
+ await addSource(tester, 'myblog', kind: 'Learning');
await tester.tap(tab('Learn'));
await tester.pumpAndSettle();
@@ -170,7 +170,7 @@ void main() {
await tester.tap(tab('Sources'));
await tester.pump();
- await addFeed(tester, 'mycomic', kind: 'Fun');
+ await addSource(tester, 'mycomic', kind: 'Fun');
await tester.tap(tab('Learn'));
await tester.pumpAndSettle();
diff --git a/test/support/fake_app.dart b/test/support/fake_app.dart
index 6fe0d65..daad923 100644
--- a/test/support/fake_app.dart
+++ b/test/support/fake_app.dart
@@ -14,7 +14,7 @@ class FakeFeeds extends FeedService {
/// Feed URL -> (title, age) of each article in the feed.
final Map> byFeedUrl;
- /// How often the app refreshed its feeds.
+ /// How often the app refreshed all Sources.
int refreshes = 0;
@override
--
2.54.0
From 3b54b3515b2388a653daf928d4f9d47dd2f5e2dc Mon Sep 17 00:00:00 2001
From: Cedric Hornberger
Date: Fri, 16 Oct 2026 16:09:43 +0200
Subject: [PATCH 14/14] Bring the README in line with the source kinds (review
items 1, 2)
The catalog has 42 Sources (13 German, 29 English; 23 News, 16 Learning,
3 Fun). Today shows News sources only, Learn has a Learning (14 days) and
a Fun (3 days) section, neither feeds the Briefing, and the time window
setting is for News. The project layout lists the UI files including
learn_screen.dart.
Co-Authored-By: Claude Opus 5.5
---
README.md | 54 +++++++++++++++++++++++++++++++++---------------------
1 file changed, 33 insertions(+), 21 deletions(-)
diff --git a/README.md b/README.md
index bdb211c..5886d25 100644
--- a/README.md
+++ b/README.md
@@ -6,29 +6,32 @@ or write a daily briefing. It's meant to replace the n8n → Discord workflow.
## Features (prototype)
-- **Today**: all articles from the enabled sources in the last 12/24/48 h, newest
- first. Filter by language or source, pull to refresh.
-- **Learn**: articles from the enabled Learning sources (engineering blogs,
- system design explainers, AI research write-ups) from the last 14 days, newest
- first. Learning sources aren't part of the Briefing.
+- **Today**: all articles from the enabled News sources in the last 12/24/48 h,
+ newest first. Filter by language or source, pull to refresh.
+- **Learn**: two sections, newest first. **Learning** has posts from the enabled
+ Learning sources (engineering blogs, system design explainers, AI research
+ write-ups) from the last 14 days; **Fun** has comics, memes and IT horror
+ stories from the enabled Fun sources from the last 3 days. Neither is part of
+ the Briefing.
- **Reading in the app**: tapping an article opens a clean **reader view** (text,
headings, lists, quotes, images) without ads or cookie banners. The globe icon
switches to the **original page** in an in-app WebView, for paywalls or sites
that block reader view. **Summarize with AI** uses the same downloaded text, so
the summary is based on the full article, not just the RSS teaser. Links in the
briefing open the same way.
-- **Briefing**: AI picks the 6–10 most important stories of the day, merges
- coverage from different outlets, and links every topic to its sources. Every
- briefing is saved on the device (`briefings.json` in the app's private
- storage); the history button lists them by month, tap to read, swipe to delete
- (with undo).
+- **Briefing**: AI picks the 6–10 most important stories of the day from the
+ News sources, merges coverage from different outlets, and links every topic to
+ its articles. Every briefing is saved on the device (`briefings.json` in the
+ app's private storage); the history button lists them by month, tap to read,
+ swipe to delete (with undo).
- **Paid articles and ads hidden**: heise+ and Golem `(g+)` are recognized by
their title; SPIEGEL+ and t3n Pro by a marker in the article page, which the app
checks once per new article in the background (it reads only until the marker,
- and remembers results for 14 days). Paid-subscriber posts from Substack newsletters
+ and remembers results for 14 days). Paid-subscriber posts from Sources on Substack
(Interconnects, Ahead of AI, …) are recognized in the feed itself, where Substack
cuts them off with a "Read more" link. Sponsored posts (`Anzeige:`) are hidden too.
- The feed header shows how many were hidden; both filters can be turned off.
+ The header of Today and of each Learn section shows how many were hidden; both
+ filters can be turned off.
- **Ad and cookie-banner blocking** in the original-page view: requests to ~80
ad/tracker hosts and the common consent managers (Sourcepoint, OneTrust,
consentmanager, Usercentrics, Didomi, Cookiebot, …) are blocked, and a
@@ -38,16 +41,17 @@ or write a daily briefing. It's meant to replace the n8n → Discord workflow.
same request as Golem's own "Zustimmen" button (`/abo/setconsentcookie.php`,
valid for a day) and reuses the cookie for reader view and the in-app page.
With it off, the app shows the teaser and an "Open website anyway" option.
-- **Sources**: 23 curated feeds (13 German, 10 English), toggle each one, or add any
- RSS/Atom feed as a News, Learning or Fun source (it's validated before being
- added). The tab groups Sources by kind, then by language.
+- **Sources**: 42 curated Sources (13 German, 29 English): 23 News, 16 Learning
+ and 3 Fun sources. Toggle each one, or add any RSS/Atom feed as a News,
+ Learning or Fun source (it's validated before being added). The tab groups
+ Sources by kind, then by language.
- **Dark mode**: System / Light / Dark in Settings (applies instantly). The
original-page view follows it too: when the phone is dark, sites use their own
dark design or Android darkens them; when only the app is dark, the app inverts
the page colours and keeps photos and videos as they are.
- **Settings**: AI provider (off / Claude API / n8n webhook), model, summary
- language (German/English, whatever the source language), time window, paid/ad
- filters, ad and cookie-banner blocking.
+ language (German/English, whatever the source language), time window for News,
+ paid/ad filters, ad and cookie-banner blocking.
## Running
@@ -236,8 +240,8 @@ Reddit blocks reader view, so a meme shows the picture from the feed.
```
lib/
main.dart App entry, theme
- data/default_sources.dart Curated feed list
- models/ Article, Source, DailyBriefing, AppSettings
+ data/default_sources.dart Curated Source catalog (News, Learning, Fun)
+ models/ Article, Source and its kind, DailyBriefing, AppSettings
services/
feed_parser.dart RSS 2.0 / RSS 1.0 / Atom parser, date parsing
feed_service.dart Parallel fetching, dedupe, time window
@@ -246,9 +250,17 @@ lib/
claude_summarizer.dart Claude Messages API over HTTP
n8n_summarizer.dart n8n webhook bridge
storage.dart Settings, sources, briefing persistence
- paywall_service.dart Paid/sponsored detection (titles + page marker)
+ paywall_service.dart Paid/sponsored detection (titles, Substack cut-off, page marker)
content_blocking.dart Ad/tracker/consent blocklists for the WebView
consent_service.dart Accepts consent walls (Golem) and keeps the cookies
state/app_state.dart ChangeNotifier holding app state
- ui/ Screens (Today, Article, Briefing, Sources, Settings)
+ ui/
+ home_shell.dart Tabs: Today, Briefing, Learn, Sources, Settings
+ feed_screen.dart Today: News articles with language/source filters
+ briefing_screen.dart Briefing (briefing_history_screen.dart: saved ones)
+ learn_screen.dart Learn: Learning and Fun sections
+ sources_screen.dart Sources by kind and language, adding a Source
+ settings_screen.dart Settings
+ article_screen.dart Reader view and original page
+ common.dart Shared helpers and widgets
```
--
2.54.0