3 Commits
Author SHA1 Message Date
CedricandClaude Opus 5.5 c86609424c Update the News source catalog for quality (#5)
Adds Techmeme, Lobsters, The Decoder and Simon Willison, all on by default.
Hacker News now reads hnrss.org's front page with 150+ points. t3n,
ComputerBase, TechCrunch and The Verge stay in the catalog but are off by
default. heise Security and heise Developer are described as sections of
heise online. WIRED and Engadget are removed.

Saved toggles for Sources that are no longer in the catalog are dropped on
launch, so they don't count towards "n of m enabled". The README source
table matches the catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:42:01 +02:00
CedricandClaude Opus 5.5 8c8ffca5dc Ignore Claude Code agent worktrees
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:36:17 +02:00
CedricandClaude Opus 5.5 ea37a23c08 Switch newly added default Sources on once (#1)
The app now remembers which curated Sources it has seen. A Source that is on
by default is enabled the first time it appears, also on installs with saved
toggles; Sources seen before keep the user's choice. Installs from before this
change count the current catalog as seen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:35:36 +02:00
7 changed files with 271 additions and 42 deletions

No files matched your search

+3
View File
@@ -63,3 +63,6 @@ key.properties
# Flutter version manager
.fvm/
# Claude Code agent worktrees
.claude/worktrees/
+19 -13
View File
@@ -33,7 +33,7 @@ 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**: 21 curated feeds (12 German, 9 English), toggle each one, or add any
- **Sources**: 23 curated feeds (13 German, 10 English), toggle each one, or add any
RSS/Atom feed (it's validated before being added).
- **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
@@ -145,34 +145,40 @@ existing AI nodes.
## News sources
Every feed URL below was checked to load and parse. The ★ sources are enabled by
default.
default: a small set of high-signal News sources. The others stay in the catalog
to switch on in the Sources tab.
| | Source | Feed | Focus |
|---|---|---|---|
| 🇩🇪 ★ | heise online | `heise.de/rss/heise-atom.xml` | Broad IT news, high volume (~150/day) |
| 🇩🇪 ★ | Golem.de | `rss.golem.de/rss.php?feed=ATOM1.0` | IT news for professionals |
| 🇩🇪 ★ | t3n | `t3n.de/rss.xml` | Digital business, startups, AI |
| 🇩🇪 ★ | ComputerBase | `computerbase.de/rss/news.xml` | Hardware, software, gaming |
| 🇩🇪 ★ | The Decoder | `the-decoder.de/feed/` | AI news: models, research, industry |
| 🇩🇪 | t3n | `t3n.de/rss.xml` | Digital business, startups, AI |
| 🇩🇪 | ComputerBase | `computerbase.de/rss/news.xml` | Hardware, software, gaming |
| 🇩🇪 | SPIEGEL Netzwelt | `spiegel.de/netzwelt/index.rss` | Mainstream news magazine view |
| 🇩🇪 | ZEIT Digital | `newsfeed.zeit.de/digital/index` | Digital policy and society |
| 🇩🇪 | netzpolitik.org | `netzpolitik.org/feed/` | Digital rights, privacy |
| 🇩🇪 | heise Security | `heise.de/security/rss/news-atom.xml` | Security vulnerabilities |
| 🇩🇪 | heise Developer | `heise.de/developer/rss/news-atom.xml` | Software development |
| 🇩🇪 | heise Security | `heise.de/security/rss/news-atom.xml` | Security section of heise online (turn heise online off to use it) |
| 🇩🇪 | heise Developer | `heise.de/developer/rss/news-atom.xml` | Developer section of heise online (turn heise online off to use it) |
| 🇩🇪 | Caschys Blog | `stadt-bremerhaven.de/feed/` | Consumer tech, smartphones |
| 🇩🇪 | derStandard Web | `derstandard.at/rss/web` | Austrian daily's tech section |
| 🇩🇪 | Borns IT- und Windows-Blog | `borncity.com/blog/feed/` | Windows, patches, admin topics |
| 🇬🇧 ★ | The Verge | `theverge.com/rss/index.xml` | Consumer tech, policy |
| 🇬🇧 | The Verge | `theverge.com/rss/index.xml` | Consumer tech, policy |
| 🇬🇧 ★ | Ars Technica | `feeds.arstechnica.com/arstechnica/index` | In-depth tech and science |
| 🇬🇧 ★ | TechCrunch | `techcrunch.com/feed/` | Startups, funding |
| 🇬🇧 ★ | Hacker News | `news.ycombinator.com/rss` | Front page links |
| 🇬🇧 | TechCrunch | `techcrunch.com/feed/` | Startups, funding |
| 🇬🇧 ★ | Hacker News | `hnrss.org/frontpage?points=150` | Front page stories with 150+ points |
| 🇬🇧 ★ | Techmeme | `techmeme.com/feed.xml` | The day's top tech stories, picked by editors |
| 🇬🇧 ★ | Lobsters | `lobste.rs/rss` | Computing links, voted by a programmer community |
| 🇬🇧 ★ | Simon Willison | `simonwillison.net/atom/entries/` | LLMs and AI tools, tried out hands-on |
| 🇬🇧 | MIT Technology Review | `technologyreview.com/feed/` | AI, biotech, climate |
| 🇬🇧 | WIRED | `wired.com/feed/rss` | Tech and culture |
| 🇬🇧 | Engadget | `engadget.com/rss.xml` | Gadgets |
| 🇬🇧 | The Register | `theregister.com/headlines.atom` | Enterprise IT |
| 🇬🇧 | BleepingComputer | `bleepingcomputer.com/feed/` | Security, malware |
Reader view works for 20 of the 21 sources. ZEIT Digital blocks non-browser
requests, so the app shows the teaser and offers the original page.
Reader view works for the publishers' own articles except ZEIT Digital, which
blocks non-browser requests, so the app shows the teaser and offers the original
page. Hacker News and Lobsters link to articles all over the web, so reader view
depends on the linked site. Techmeme links to its own summary page, which only
has the teaser; the original page links on to the full story.
## Project layout
+49 -24
View File
@@ -22,6 +22,15 @@ const defaultSources = <NewsSource>[
description: 'IT-News für Profis',
enabledByDefault: true,
),
NewsSource(
id: 'the-decoder',
name: 'The Decoder',
feedUrl: 'https://the-decoder.de/feed/',
homepage: 'https://the-decoder.de',
language: SourceLanguage.de,
description: 'KI-News: Modelle, Forschung, Branche',
enabledByDefault: true,
),
NewsSource(
id: 't3n',
name: 't3n',
@@ -29,7 +38,6 @@ const defaultSources = <NewsSource>[
homepage: 'https://t3n.de',
language: SourceLanguage.de,
description: 'Digitales Business, Startups, KI',
enabledByDefault: true,
checkPaywallOnPage: true,
),
NewsSource(
@@ -39,7 +47,6 @@ const defaultSources = <NewsSource>[
homepage: 'https://www.computerbase.de',
language: SourceLanguage.de,
description: 'Hardware, Software, Gaming',
enabledByDefault: true,
),
NewsSource(
id: 'spiegel-netzwelt',
@@ -72,7 +79,7 @@ const defaultSources = <NewsSource>[
feedUrl: 'https://www.heise.de/security/rss/news-atom.xml',
homepage: 'https://www.heise.de/security/',
language: SourceLanguage.de,
description: 'Sicherheitslücken und Security-News',
description: 'Security-Teil von heise online (heise online ausschalten, um ihn einzeln zu lesen)',
),
NewsSource(
id: 'heise-developer',
@@ -80,7 +87,7 @@ const defaultSources = <NewsSource>[
feedUrl: 'https://www.heise.de/developer/rss/news-atom.xml',
homepage: 'https://www.heise.de/developer/',
language: SourceLanguage.de,
description: 'Softwareentwicklung',
description: 'Developer-Teil von heise online (heise online ausschalten, um ihn einzeln zu lesen)',
),
NewsSource(
id: 'caschy',
@@ -115,7 +122,6 @@ const defaultSources = <NewsSource>[
homepage: 'https://www.theverge.com',
language: SourceLanguage.en,
description: 'Consumer tech, culture, policy',
enabledByDefault: true,
),
NewsSource(
id: 'arstechnica',
@@ -133,15 +139,41 @@ const defaultSources = <NewsSource>[
homepage: 'https://techcrunch.com',
language: SourceLanguage.en,
description: 'Startups, funding, big tech',
enabledByDefault: true,
),
NewsSource(
id: 'hackernews',
name: 'Hacker News',
feedUrl: 'https://news.ycombinator.com/rss',
feedUrl: 'https://hnrss.org/frontpage?points=150',
homepage: 'https://news.ycombinator.com',
language: SourceLanguage.en,
description: 'Front page links, ranked by readers',
description: 'Front page stories with 150+ points',
enabledByDefault: true,
),
NewsSource(
id: 'techmeme',
name: 'Techmeme',
feedUrl: 'https://www.techmeme.com/feed.xml',
homepage: 'https://www.techmeme.com',
language: SourceLanguage.en,
description: "The day's top tech stories, picked by editors",
enabledByDefault: true,
),
NewsSource(
id: 'lobsters',
name: 'Lobsters',
feedUrl: 'https://lobste.rs/rss',
homepage: 'https://lobste.rs',
language: SourceLanguage.en,
description: 'Computing links, voted by a programmer community',
enabledByDefault: true,
),
NewsSource(
id: 'simonwillison',
name: 'Simon Willison',
feedUrl: 'https://simonwillison.net/atom/entries/',
homepage: 'https://simonwillison.net',
language: SourceLanguage.en,
description: 'LLMs and AI tools, tried out hands-on',
enabledByDefault: true,
),
NewsSource(
@@ -152,22 +184,6 @@ const defaultSources = <NewsSource>[
language: SourceLanguage.en,
description: 'AI, biotech, climate tech',
),
NewsSource(
id: 'wired',
name: 'WIRED',
feedUrl: 'https://www.wired.com/feed/rss',
homepage: 'https://www.wired.com',
language: SourceLanguage.en,
description: 'Tech, science and culture',
),
NewsSource(
id: 'engadget',
name: 'Engadget',
feedUrl: 'https://www.engadget.com/rss.xml',
homepage: 'https://www.engadget.com',
language: SourceLanguage.en,
description: 'Gadgets and consumer electronics',
),
NewsSource(
id: 'theregister',
name: 'The Register',
@@ -185,3 +201,12 @@ const defaultSources = <NewsSource>[
description: 'Security and malware news',
),
];
/// The curated sources before the app remembered which ones it had seen.
/// Installs from then count these as seen, so switched-off ones stay off.
const legacySeenSourceIds = {
'heise', 'golem', 't3n', 'computerbase', 'spiegel-netzwelt', 'zeit-digital',
'netzpolitik', 'heise-security', 'heise-developer', 'caschy', 'derstandard-web',
'borncity', 'verge', 'arstechnica', 'techcrunch', 'hackernews', 'mit-tr', 'wired',
'engadget', 'theregister', 'bleepingcomputer',
};
+7
View File
@@ -74,6 +74,13 @@ class Storage {
Future<void> saveEnabledSourceIds(Set<String> ids) =>
_prefs.setStringList('enabled_sources', ids.toList());
/// Curated sources the app has already offered, so each one that is on by
/// default switches on only once. Null means "never saved".
Set<String>? loadSeenSourceIds() => _prefs.getStringList('seen_sources')?.toSet();
Future<void> saveSeenSourceIds(Set<String> ids) =>
_prefs.setStringList('seen_sources', ids.toList());
List<NewsSource> loadCustomSources() {
final raw = _prefs.getString('custom_sources');
if (raw == null) return [];
+33 -5
View File
@@ -29,8 +29,13 @@ class SummaryState {
}
class AppState extends ChangeNotifier {
AppState(this._storage, {FeedService? feedService, ArticleExtractor? extractor})
: _feeds = feedService ?? FeedService(),
AppState(
this._storage, {
FeedService? feedService,
ArticleExtractor? extractor,
List<NewsSource> curatedSources = defaultSources,
}) : _feeds = feedService ?? FeedService(),
_curated = curatedSources,
consent = ConsentService(),
paywall = PaywallService(_storage) {
_extractor = extractor ?? ArticleExtractor(consent: consent);
@@ -38,6 +43,9 @@ class AppState extends ChangeNotifier {
final Storage _storage;
final FeedService _feeds;
/// The curated catalog; [defaultSources] outside of tests.
final List<NewsSource> _curated;
late final ArticleExtractor _extractor;
final PaywallService paywall;
@@ -74,7 +82,7 @@ class AppState extends ChangeNotifier {
int get hiddenCount => articles.where(_isHidden).length;
List<NewsSource> get allSources => [...defaultSources, ...customSources];
List<NewsSource> get allSources => [..._curated, ...customSources];
List<NewsSource> get enabledSources =>
allSources.where((s) => enabledSourceIds.contains(s.id)).toList();
@@ -82,8 +90,7 @@ class AppState extends ChangeNotifier {
Future<void> init() async {
settings = await _storage.loadSettings();
customSources = _storage.loadCustomSources();
enabledSourceIds = _storage.loadEnabledSourceIds() ??
defaultSources.where((s) => s.enabledByDefault).map((s) => s.id).toSet();
await _enableNewDefaultSources();
briefings = await _storage.loadBriefingHistory();
notifyListeners();
await refresh();
@@ -111,6 +118,27 @@ class AppState extends ChangeNotifier {
// --- Sources -------------------------------------------------------------
/// Switches on curated sources that are on by default the first time the
/// app sees them, so they also reach installs with saved toggles. Sources
/// seen before keep whatever the user chose. Saved ids of Sources that are
/// no longer in the catalog are dropped.
Future<void> _enableNewDefaultSources() async {
final saved = _storage.loadEnabledSourceIds();
final seen = _storage.loadSeenSourceIds() ??
(saved == null ? const <String>{} : legacySeenSourceIds);
final unseen = _curated.where((s) => !seen.contains(s.id)).toList();
final known = {for (final s in allSources) s.id};
enabledSourceIds = {
...?saved?.where(known.contains),
for (final s in unseen)
if (s.enabledByDefault) s.id,
};
final dropped = saved != null && saved.any((id) => !known.contains(id));
if (unseen.isEmpty && !dropped) return;
await _storage.saveSeenSourceIds({...seen, ...unseen.map((s) => s.id)});
await _storage.saveEnabledSourceIds(enabledSourceIds);
}
Future<void> setSourceEnabled(NewsSource source, bool enabled) async {
enabled ? enabledSourceIds.add(source.id) : enabledSourceIds.remove(source.id);
notifyListeners();
+51
View File
@@ -0,0 +1,51 @@
import 'package:daily_tech_news/data/default_sources.dart';
import 'package:daily_tech_news/models/news_source.dart';
import 'package:flutter_test/flutter_test.dart';
NewsSource _byId(String id) => defaultSources.singleWhere((s) => s.id == id);
void main() {
test('the News sources on by default are the curated quality set', () {
final onByDefault = {
for (final s in defaultSources)
if (s.enabledByDefault) s.id,
};
expect(onByDefault, {
'heise', 'golem', 'arstechnica', 'hackernews',
'techmeme', 'lobsters', 'the-decoder', 'simonwillison',
});
});
test('the added Sources read the agreed feeds', () {
final feeds = {
for (final id in ['techmeme', 'lobsters', 'the-decoder', 'simonwillison'])
id: (_byId(id).feedUrl, _byId(id).language),
};
expect(feeds, {
'techmeme': ('https://www.techmeme.com/feed.xml', SourceLanguage.en),
'lobsters': ('https://lobste.rs/rss', SourceLanguage.en),
'the-decoder': ('https://the-decoder.de/feed/', SourceLanguage.de),
'simonwillison': ('https://simonwillison.net/atom/entries/', SourceLanguage.en),
});
});
test('Hacker News only brings well-voted front page stories', () {
expect(_byId('hackernews').feedUrl, 'https://hnrss.org/frontpage?points=150');
});
test('switched-off Sources stay in the catalog, WIRED and Engadget are gone', () {
final ids = defaultSources.map((s) => s.id).toSet();
expect(ids, containsAll(['t3n', 'computerbase', 'techcrunch', 'verge']));
expect(ids, isNot(contains('wired')));
expect(ids, isNot(contains('engadget')));
});
test('every Source has its own id', () {
final ids = defaultSources.map((s) => s.id).toList();
expect(ids.toSet().length, ids.length);
});
}
+109
View File
@@ -0,0 +1,109 @@
import 'package:daily_tech_news/data/default_sources.dart';
import 'package:daily_tech_news/models/news_source.dart';
import 'package:daily_tech_news/services/feed_service.dart';
import 'package:daily_tech_news/services/storage.dart';
import 'package:daily_tech_news/state/app_state.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Returns no articles, so starting the app doesn't hit the network.
class _NoFeeds extends FeedService {
@override
Future<FeedResult> fetchAll(List<NewsSource> sources,
{required Duration maxAge, String corsProxy = ''}) async =>
const FeedResult([], {});
}
NewsSource _source(String id, {bool onByDefault = false}) => NewsSource(
id: id,
name: id,
feedUrl: 'https://example.com/$id.xml',
homepage: 'https://example.com',
language: SourceLanguage.en,
enabledByDefault: onByDefault,
);
/// Starts the app the way a launch does, against whatever is in preferences.
Future<AppState> _launch(List<NewsSource> curatedSources) async {
final state =
AppState(await Storage.open(), feedService: _NoFeeds(), curatedSources: curatedSources);
await state.init();
return state;
}
void main() {
final heise = _source('heise', onByDefault: true);
final golem = _source('golem', onByDefault: true);
final wired = _source('wired');
setUp(() => SharedPreferences.setMockInitialValues({}));
test('a fresh install starts with exactly the on-by-default Sources', () async {
final state = await _launch([heise, golem, wired]);
expect(state.enabledSourceIds, {'heise', 'golem'});
});
test('a newly added on-by-default Source shows up enabled after the update', () async {
final before = await _launch([heise, golem, wired]);
await before.setSourceEnabled(wired, true);
final verge = _source('verge', onByDefault: true);
final after = await _launch([heise, golem, wired, verge]);
expect(after.enabledSourceIds, {'heise', 'golem', 'wired', 'verge'});
});
test('a Source the user switched off stays off across updates', () async {
final before = await _launch([heise, golem, wired]);
await before.setSourceEnabled(golem, false);
final verge = _source('verge', onByDefault: true);
await _launch([heise, golem, wired, verge]);
final later = await _launch([heise, golem, wired, verge]);
expect(later.enabledSourceIds, {'heise', 'verge'});
});
test('a newly added Source that is off by default stays off', () async {
final before = await _launch([heise, golem, wired]);
await before.setSourceEnabled(wired, true);
final engadget = _source('engadget');
final after = await _launch([heise, golem, wired, engadget]);
expect(after.enabledSourceIds, {'heise', 'golem', 'wired'});
});
test('an install from before the app remembered seen Sources keeps its toggles', () async {
// Toggles saved by an earlier version: golem, t3n and others switched off.
SharedPreferences.setMockInitialValues({
'enabled_sources': ['heise', 'verge', 'mit-tr'],
});
final newSource = _source('new-source', onByDefault: true);
final state = await _launch([...defaultSources, newSource]);
expect(state.enabledSourceIds, {
'heise', 'verge', 'mit-tr', 'new-source',
// Added to the catalog later, on by default.
'techmeme', 'lobsters', 'the-decoder', 'simonwillison',
});
});
test('an install from before the catalog update gets the new Sources and drops removed ones',
() async {
// Toggles saved by an earlier version, with WIRED and Engadget switched on.
SharedPreferences.setMockInitialValues({
'enabled_sources': ['heise', 'golem', 't3n', 'verge', 'wired', 'engadget'],
});
final state = await _launch(defaultSources);
expect(state.enabledSourceIds, {
'heise', 'golem', 't3n', 'verge',
'techmeme', 'lobsters', 'the-decoder', 'simonwillison',
});
expect(state.enabledSources.map((s) => s.id), isNot(contains('wired')));
});
}