diff --git a/.gitignore b/.gitignore
index 73181c6..43ec34c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,3 +63,6 @@ key.properties
# Flutter version manager
.fvm/
+
+# Claude Code agent worktrees
+.claude/worktrees/
diff --git a/README.md b/README.md
index 12765c8..5886d25 100644
--- a/README.md
+++ b/README.md
@@ -6,24 +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.
+- **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). Sponsored posts (`Anzeige:`) are hidden too.
- The feed header shows how many were hidden; both filters can be turned off.
+ 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 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
@@ -33,15 +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**: 21 curated feeds (12 German, 9 English), toggle each one, or add any
- RSS/Atom feed (it's validated before being added).
+- **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
@@ -145,42 +155,93 @@ 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.
+
+## Learning sources
+
+Evergreen reading for building skills: research, engineering depth and system
+design rather than product news. The Learn tab shows their posts from the last
+14 days. All are English; ★ marks the ones enabled by default.
+
+| | Source | Feed | Focus |
+|---|---|---|---|
+| 🇬🇧 ★ | ByteByteGo | `blog.bytebytego.com/feed` | System design explained with diagrams |
+| 🇬🇧 ★ | System Design One | `newsletter.systemdesign.one/feed` | System design case studies of real systems |
+| 🇬🇧 ★ | The Pragmatic Engineer (blog) | `blog.pragmaticengineer.com/rss/` | How big tech and startups build software (free posts) |
+| 🇬🇧 ★ | Martin Fowler | `martinfowler.com/feed.atom` | Software architecture, refactoring, delivery |
+| 🇬🇧 ★ | Julia Evans | `jvns.ca/atom.xml` | Systems, networking and Unix tools, explained plainly |
+| 🇬🇧 ★ | Dan Luu | `danluu.com/atom.xml` | Long, data-driven essays on computers and engineering |
+| 🇬🇧 ★ | The Cloudflare Blog | `blog.cloudflare.com/rss/` | Networking, security, incident write-ups |
+| 🇬🇧 ★ | Netflix Technology Blog | `netflixtechblog.com/feed` | Distributed systems, data, streaming at scale |
+| 🇬🇧 ★ | Engineering at Meta | `engineering.fb.com/feed/` | Infrastructure, AI, production engineering |
+| 🇬🇧 ★ | GitHub Blog: Engineering | `github.blog/engineering/feed/` | How GitHub builds and runs GitHub |
+| 🇬🇧 ★ | Latent Space | `www.latent.space/feed` | AI engineering: interviews and deep dives |
+| 🇬🇧 ★ | Import AI | `importai.substack.com/feed` | Weekly AI research digest by Jack Clark |
+| 🇬🇧 ★ | Interconnects | `www.interconnects.ai/feed` | Language model training and research |
+| 🇬🇧 ★ | Ahead of AI | `magazine.sebastianraschka.com/feed` | LLM research explained by Sebastian Raschka |
+| 🇬🇧 ★ | Lilian Weng | `lilianweng.github.io/index.xml` | Thorough surveys of machine learning topics |
+| 🇬🇧 | InfoQ | `feed.infoq.com/` | Architecture and engineering talks, high volume |
+
+Left out on purpose: The Pragmatic Engineer newsletter (mostly paid), One Useful
+Thing (applied AI), vendor blogs (OpenAI, DeepMind, Hugging Face) and Chip Huyen
+(inactive since January 2025). Some feeds are long (Dan Luu ~130 posts, Lilian
+Weng ~50); the 14-day window keeps the list short.
+
+## Fun sources
+
+Tech humour for the Fun section of the Learn tab, which reaches back 3 days. Fun
+articles never show up in Today or the Briefing. ★ = enabled by default.
+
+| | Source | Feed | Focus |
+|---|---|---|---|
+| 🇬🇧 ★ | xkcd | `xkcd.com/atom.xml` | Webcomic of romance, sarcasm, math and language |
+| 🇬🇧 ★ | The Daily WTF | `thedailywtf.com/rss` | IT horror stories, bad code and error messages |
+| 🇬🇧 | r/ProgrammerHumor | `reddit.com/r/ProgrammerHumor/top/.rss?t=day` | The day's top programming memes on Reddit |
+
+Reader view shows comics and memes whole instead of as a cropped banner: xkcd
+with its title text underneath, The Daily WTF's Error'd with its screenshots.
+Reddit blocks reader view, so a meme shows the picture from the feed.
## Project layout
```
lib/
main.dart App entry, theme
- data/default_sources.dart Curated feed list
- models/ Article, NewsSource, 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
@@ -189,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
```
diff --git a/lib/data/default_sources.dart b/lib/data/default_sources.dart
index fe1e6f5..07731b4 100644
--- a/lib/data/default_sources.dart
+++ b/lib/data/default_sources.dart
@@ -1,10 +1,10 @@
-import '../models/news_source.dart';
+import '../models/source.dart';
/// Curated tech news feeds. All URLs were checked to return a valid RSS/Atom
/// feed. Users can toggle them and add their own in the Sources tab.
-const defaultSources = [
+const defaultSources = [
// --- German -------------------------------------------------------------
- NewsSource(
+ Source(
id: 'heise',
name: 'heise online',
feedUrl: 'https://www.heise.de/rss/heise-atom.xml',
@@ -13,7 +13,7 @@ const defaultSources = [
description: 'IT-Nachrichten, breit aufgestellt (hohes Volumen)',
enabledByDefault: true,
),
- NewsSource(
+ Source(
id: 'golem',
name: 'Golem.de',
feedUrl: 'https://rss.golem.de/rss.php?feed=ATOM1.0',
@@ -22,26 +22,33 @@ const defaultSources = [
description: 'IT-News für Profis',
enabledByDefault: true,
),
- NewsSource(
+ Source(
+ 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,
+ ),
+ Source(
id: 't3n',
name: 't3n',
feedUrl: 'https://t3n.de/rss.xml',
homepage: 'https://t3n.de',
language: SourceLanguage.de,
description: 'Digitales Business, Startups, KI',
- enabledByDefault: true,
checkPaywallOnPage: true,
),
- NewsSource(
+ Source(
id: 'computerbase',
name: 'ComputerBase',
feedUrl: 'https://www.computerbase.de/rss/news.xml',
homepage: 'https://www.computerbase.de',
language: SourceLanguage.de,
description: 'Hardware, Software, Gaming',
- enabledByDefault: true,
),
- NewsSource(
+ Source(
id: 'spiegel-netzwelt',
name: 'SPIEGEL Netzwelt',
feedUrl: 'https://www.spiegel.de/netzwelt/index.rss',
@@ -50,7 +57,7 @@ const defaultSources = [
description: 'Digitales aus Sicht eines Nachrichtenmagazins',
checkPaywallOnPage: true,
),
- NewsSource(
+ Source(
id: 'zeit-digital',
name: 'ZEIT Digital',
feedUrl: 'https://newsfeed.zeit.de/digital/index',
@@ -58,7 +65,7 @@ const defaultSources = [
language: SourceLanguage.de,
description: 'Digitalpolitik und Gesellschaft',
),
- NewsSource(
+ Source(
id: 'netzpolitik',
name: 'netzpolitik.org',
feedUrl: 'https://netzpolitik.org/feed/',
@@ -66,23 +73,23 @@ const defaultSources = [
language: SourceLanguage.de,
description: 'Digitale Freiheitsrechte, Datenschutz',
),
- NewsSource(
+ Source(
id: 'heise-security',
name: 'heise Security',
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(
+ Source(
id: 'heise-developer',
name: 'heise Developer',
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(
+ Source(
id: 'caschy',
name: "Caschys Blog",
feedUrl: 'https://stadt-bremerhaven.de/feed/',
@@ -90,7 +97,7 @@ const defaultSources = [
language: SourceLanguage.de,
description: 'Consumer-Tech, Smartphones, Streaming',
),
- NewsSource(
+ Source(
id: 'derstandard-web',
name: 'derStandard Web',
feedUrl: 'https://www.derstandard.at/rss/web',
@@ -98,7 +105,7 @@ const defaultSources = [
language: SourceLanguage.de,
description: 'Tech-Ressort der österreichischen Tageszeitung',
),
- NewsSource(
+ Source(
id: 'borncity',
name: 'Borns IT- und Windows-Blog',
feedUrl: 'https://www.borncity.com/blog/feed/',
@@ -108,16 +115,15 @@ const defaultSources = [
),
// --- English ------------------------------------------------------------
- NewsSource(
+ Source(
id: 'verge',
name: 'The Verge',
feedUrl: 'https://www.theverge.com/rss/index.xml',
homepage: 'https://www.theverge.com',
language: SourceLanguage.en,
description: 'Consumer tech, culture, policy',
- enabledByDefault: true,
),
- NewsSource(
+ Source(
id: 'arstechnica',
name: 'Ars Technica',
feedUrl: 'https://feeds.arstechnica.com/arstechnica/index',
@@ -126,25 +132,51 @@ const defaultSources = [
description: 'In-depth tech and science',
enabledByDefault: true,
),
- NewsSource(
+ Source(
id: 'techcrunch',
name: 'TechCrunch',
feedUrl: 'https://techcrunch.com/feed/',
homepage: 'https://techcrunch.com',
language: SourceLanguage.en,
description: 'Startups, funding, big tech',
- enabledByDefault: true,
),
- NewsSource(
+ Source(
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(
+ Source(
+ 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,
+ ),
+ Source(
+ 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,
+ ),
+ Source(
+ 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,
+ ),
+ Source(
id: 'mit-tr',
name: 'MIT Technology Review',
feedUrl: 'https://www.technologyreview.com/feed/',
@@ -152,23 +184,7 @@ const defaultSources = [
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(
+ Source(
id: 'theregister',
name: 'The Register',
feedUrl: 'https://www.theregister.com/headlines.atom',
@@ -176,7 +192,7 @@ const defaultSources = [
language: SourceLanguage.en,
description: 'Enterprise IT with attitude',
),
- NewsSource(
+ Source(
id: 'bleepingcomputer',
name: 'BleepingComputer',
feedUrl: 'https://www.bleepingcomputer.com/feed/',
@@ -184,4 +200,205 @@ const defaultSources = [
language: SourceLanguage.en,
description: 'Security and malware news',
),
+
+ // --- Learning -----------------------------------------------------------
+ Source(
+ id: 'bytebytego',
+ name: 'ByteByteGo',
+ feedUrl: 'https://blog.bytebytego.com/feed',
+ homepage: 'https://blog.bytebytego.com',
+ language: SourceLanguage.en,
+ kind: SourceKind.learning,
+ description: 'System design explained with diagrams',
+ enabledByDefault: true,
+ ),
+ Source(
+ id: 'systemdesignone',
+ name: 'System Design One',
+ feedUrl: 'https://newsletter.systemdesign.one/feed',
+ homepage: 'https://newsletter.systemdesign.one',
+ language: SourceLanguage.en,
+ kind: SourceKind.learning,
+ description: 'System design case studies of real systems',
+ enabledByDefault: true,
+ ),
+ Source(
+ id: 'pragmaticengineer-blog',
+ name: 'The Pragmatic Engineer (blog)',
+ feedUrl: 'https://blog.pragmaticengineer.com/rss/',
+ homepage: 'https://blog.pragmaticengineer.com',
+ language: SourceLanguage.en,
+ kind: SourceKind.learning,
+ description: 'How big tech and startups build software (free posts)',
+ enabledByDefault: true,
+ ),
+ Source(
+ id: 'martinfowler',
+ name: 'Martin Fowler',
+ feedUrl: 'https://martinfowler.com/feed.atom',
+ homepage: 'https://martinfowler.com',
+ language: SourceLanguage.en,
+ kind: SourceKind.learning,
+ description: 'Software architecture, refactoring, delivery practices',
+ enabledByDefault: true,
+ ),
+ Source(
+ id: 'juliaevans',
+ name: 'Julia Evans',
+ feedUrl: 'https://jvns.ca/atom.xml',
+ homepage: 'https://jvns.ca',
+ language: SourceLanguage.en,
+ kind: SourceKind.learning,
+ description: 'Systems, networking and Unix tools, explained plainly',
+ enabledByDefault: true,
+ ),
+ Source(
+ id: 'danluu',
+ name: 'Dan Luu',
+ feedUrl: 'https://danluu.com/atom.xml',
+ homepage: 'https://danluu.com',
+ language: SourceLanguage.en,
+ kind: SourceKind.learning,
+ description: 'Long, data-driven essays on computers and engineering',
+ enabledByDefault: true,
+ ),
+ Source(
+ id: 'cloudflare-blog',
+ name: 'The Cloudflare Blog',
+ feedUrl: 'https://blog.cloudflare.com/rss/',
+ homepage: 'https://blog.cloudflare.com',
+ language: SourceLanguage.en,
+ kind: SourceKind.learning,
+ description: 'Networking, security and incident write-ups in depth',
+ enabledByDefault: true,
+ ),
+ Source(
+ id: 'netflix-techblog',
+ name: 'Netflix Technology Blog',
+ feedUrl: 'https://netflixtechblog.com/feed',
+ homepage: 'https://netflixtechblog.com',
+ language: SourceLanguage.en,
+ kind: SourceKind.learning,
+ description: 'Distributed systems, data and streaming at scale',
+ enabledByDefault: true,
+ ),
+ Source(
+ id: 'meta-engineering',
+ name: 'Engineering at Meta',
+ feedUrl: 'https://engineering.fb.com/feed/',
+ homepage: 'https://engineering.fb.com',
+ language: SourceLanguage.en,
+ kind: SourceKind.learning,
+ description: 'Infrastructure, AI and production engineering at Meta',
+ enabledByDefault: true,
+ ),
+ Source(
+ id: 'github-engineering',
+ name: 'GitHub Blog: Engineering',
+ feedUrl: 'https://github.blog/engineering/feed/',
+ homepage: 'https://github.blog/engineering/',
+ language: SourceLanguage.en,
+ kind: SourceKind.learning,
+ description: 'How GitHub builds and runs GitHub',
+ enabledByDefault: true,
+ ),
+ Source(
+ id: 'latent-space',
+ name: 'Latent Space',
+ feedUrl: 'https://www.latent.space/feed',
+ homepage: 'https://www.latent.space',
+ language: SourceLanguage.en,
+ kind: SourceKind.learning,
+ description: 'AI engineering: interviews and deep dives',
+ enabledByDefault: true,
+ ),
+ Source(
+ id: 'import-ai',
+ name: 'Import AI',
+ feedUrl: 'https://importai.substack.com/feed',
+ homepage: 'https://importai.substack.com',
+ language: SourceLanguage.en,
+ kind: SourceKind.learning,
+ description: 'Weekly AI research digest by Jack Clark',
+ enabledByDefault: true,
+ ),
+ Source(
+ id: 'interconnects',
+ name: 'Interconnects',
+ feedUrl: 'https://www.interconnects.ai/feed',
+ homepage: 'https://www.interconnects.ai',
+ language: SourceLanguage.en,
+ kind: SourceKind.learning,
+ description: 'Language model training and research, analysed',
+ enabledByDefault: true,
+ ),
+ Source(
+ id: 'ahead-of-ai',
+ name: 'Ahead of AI',
+ feedUrl: 'https://magazine.sebastianraschka.com/feed',
+ homepage: 'https://magazine.sebastianraschka.com',
+ language: SourceLanguage.en,
+ kind: SourceKind.learning,
+ description: 'LLM research explained, by Sebastian Raschka',
+ enabledByDefault: true,
+ ),
+ Source(
+ id: 'lilianweng',
+ name: 'Lilian Weng',
+ feedUrl: 'https://lilianweng.github.io/index.xml',
+ homepage: 'https://lilianweng.github.io',
+ language: SourceLanguage.en,
+ kind: SourceKind.learning,
+ description: 'Thorough surveys of machine learning topics',
+ enabledByDefault: true,
+ ),
+ Source(
+ id: 'infoq',
+ name: 'InfoQ',
+ feedUrl: 'https://feed.infoq.com/',
+ homepage: 'https://www.infoq.com',
+ language: SourceLanguage.en,
+ kind: SourceKind.learning,
+ description: 'Software architecture and engineering talks, high volume',
+ ),
+
+ // --- Fun ----------------------------------------------------------------
+ Source(
+ id: 'xkcd',
+ name: 'xkcd',
+ feedUrl: 'https://xkcd.com/atom.xml',
+ homepage: 'https://xkcd.com',
+ language: SourceLanguage.en,
+ kind: SourceKind.fun,
+ description: 'Webcomic of romance, sarcasm, math and language',
+ enabledByDefault: true,
+ ),
+ Source(
+ id: 'thedailywtf',
+ name: 'The Daily WTF',
+ feedUrl: 'https://thedailywtf.com/rss',
+ homepage: 'https://thedailywtf.com',
+ language: SourceLanguage.en,
+ kind: SourceKind.fun,
+ description: 'IT horror stories, bad code and error messages',
+ enabledByDefault: true,
+ ),
+ Source(
+ id: 'programmerhumor',
+ name: 'r/ProgrammerHumor',
+ feedUrl: 'https://www.reddit.com/r/ProgrammerHumor/top/.rss?t=day',
+ homepage: 'https://www.reddit.com/r/ProgrammerHumor/',
+ language: SourceLanguage.en,
+ kind: SourceKind.fun,
+ description: "The day's top programming memes on Reddit",
+ ),
];
+
+/// 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',
+};
diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart
index 42514a8..9ea9b83 100644
--- a/lib/models/app_settings.dart
+++ b/lib/models/app_settings.dart
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart' show ThemeMode;
-import 'news_source.dart';
+import 'source.dart';
enum AiProvider {
none('Off'),
diff --git a/lib/models/article.dart b/lib/models/article.dart
index 08de71b..89cd7c5 100644
--- a/lib/models/article.dart
+++ b/lib/models/article.dart
@@ -1,4 +1,4 @@
-import 'news_source.dart';
+import 'source.dart';
class Article {
const Article({
@@ -14,7 +14,7 @@ class Article {
final String title;
final String link;
- final NewsSource source;
+ final Source source;
final DateTime? published;
/// Short plain-text teaser from the feed (description / summary).
diff --git a/lib/models/news_source.dart b/lib/models/source.dart
similarity index 54%
rename from lib/models/news_source.dart
rename to lib/models/source.dart
index 63d47b5..f62ad78 100644
--- a/lib/models/news_source.dart
+++ b/lib/models/source.dart
@@ -8,13 +8,42 @@ enum SourceLanguage {
final String flag;
}
-class NewsSource {
- const NewsSource({
+/// 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', feedsBriefing: true),
+ learning('Learning', fixedWindow: Duration(days: 14)),
+ fun('Fun', fixedWindow: Duration(days: 3), pictureLed: true);
+
+ 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.
+class Source {
+ const Source({
required this.id,
required this.name,
required this.feedUrl,
required this.homepage,
required this.language,
+ this.kind = SourceKind.news,
this.description = '',
this.enabledByDefault = false,
this.isCustom = false,
@@ -26,6 +55,7 @@ class NewsSource {
final String feedUrl;
final String homepage;
final SourceLanguage language;
+ final SourceKind kind;
final String description;
final bool enabledByDefault;
final bool isCustom;
@@ -40,15 +70,18 @@ class NewsSource {
'feedUrl': feedUrl,
'homepage': homepage,
'language': language.name,
+ 'kind': kind.name,
'description': description,
};
- factory NewsSource.custom(Map json) => NewsSource(
+ factory Source.custom(Map json) => Source(
id: json['id'] as String,
name: json['name'] as String,
feedUrl: json['feedUrl'] as String,
homepage: json['homepage'] as String? ?? '',
language: SourceLanguage.values.byName(json['language'] as String? ?? 'en'),
+ // Custom sources saved before kinds existed are News sources.
+ kind: SourceKind.values.asNameMap()[json['kind']] ?? SourceKind.news,
description: json['description'] as String? ?? '',
isCustom: true,
);
diff --git a/lib/services/article_extractor.dart b/lib/services/article_extractor.dart
index 41ad6ac..54bf686 100644
--- a/lib/services/article_extractor.dart
+++ b/lib/services/article_extractor.dart
@@ -49,9 +49,10 @@ class ArticleExtractor {
/// Hard cap on what we send to the model per article (~10k tokens).
static const maxChars = 40000;
- /// Loads the article page and returns whichever is longer: the extracted
- /// page or the full content some feeds ship. Never throws; on failure
- /// (paywall, bot protection, CORS on web) it falls back to feed content.
+ /// Loads the article page and returns whichever has more text (on a tie,
+ /// more blocks): the extracted page or the full content some feeds ship.
+ /// Never throws; on failure (paywall, bot protection, CORS on web) it falls
+ /// back to feed content.
///
/// With [acceptConsentWalls], a redirect to a consent page the
/// [ConsentService] knows is accepted once and the article fetched again.
@@ -81,7 +82,12 @@ class ArticleExtractor {
if (page.wallUrl != null) return ArticleContent(fromFeed.blocks, consentWall: true);
if (page.status != 200) return fromFeed;
final fromPage = parse(FeedService.decodeBody(page.bytes), base);
- return fromPage.plainText.length > fromFeed.plainText.length ? fromPage : fromFeed;
+ final pageText = fromPage.plainText.length, feedText = fromFeed.plainText.length;
+ // Without text either way (a comic), the one with the picture wins.
+ return pageText > feedText ||
+ (pageText == feedText && fromPage.blocks.length > fromFeed.blocks.length)
+ ? fromPage
+ : fromFeed;
} catch (_) {
return fromFeed;
}
@@ -119,7 +125,8 @@ class ArticleExtractor {
/// Class/id fragments of page furniture that sits inside article bodies.
static final _junkMarker = RegExp(
r'comment|related|newsletter|share|social|promo|advert|paywall|subscribe|'
- r'recommend|author-box|byline|breadcrumb|tags|toolbar|consent|cookie|card|native-ad',
+ r'recommend|author-box|byline|breadcrumb|tags|toolbar|consent|cookie|card|native-ad|'
+ r'license',
caseSensitive: false,
);
@@ -155,14 +162,15 @@ class ArticleExtractor {
final blocks = [];
final seenImages = {};
var chars = 0;
- void add(BlockType type, String text) {
- if (chars > maxChars) return;
+ bool add(BlockType type, String text) {
+ if (chars > maxChars) return false;
if (type == BlockType.image) {
- if (!seenImages.add(text)) return;
+ if (!seenImages.add(text)) return false;
} else {
chars += text.length;
}
blocks.add(ReaderBlock(type, text));
+ return true;
}
void walk(dom.Element el) {
@@ -190,6 +198,8 @@ class ArticleExtractor {
case 'blockquote':
final t = _clean(el.text);
if (t.isNotEmpty) add(BlockType.quote, t);
+ // Quoted screenshots, as in The Daily WTF's Error'd.
+ el.querySelectorAll('img').forEach((img) => _addImage(img, base, add));
return;
case 'pre':
final t = el.text.trimRight();
@@ -221,7 +231,7 @@ class ArticleExtractor {
static String _clean(String s) => s.replaceAll(RegExp(r'\s+'), ' ').trim();
static void _addImage(
- dom.Element img, Uri base, void Function(BlockType, String) add) {
+ dom.Element img, Uri base, bool Function(BlockType, String) add) {
// Lazy-loading sites keep the real URL in data attributes.
final raw = img.attributes['data-src'] ??
img.attributes['data-lazy-src'] ??
@@ -232,19 +242,25 @@ class ArticleExtractor {
}
final width = int.tryParse(img.attributes['width'] ?? '');
if (width != null && width < 120) return; // icons, avatars, tracking pixels
+ if (img.attributes.containsKey('usemap')) return; // clickable navigation
// Images linking to another page are author avatars or related-story
- // thumbnails; links to the full-size image file are fine.
+ // thumbnails; links to the full-size image file or within the page are fine.
for (var p = img.parent; p != null; p = p.parent) {
if (p.localName == 'a') {
final href = p.attributes['href'] ?? '';
- if (!RegExp(r'\.(jpe?g|png|webp|gif|avif)(\?|$)', caseSensitive: false)
- .hasMatch(href)) {
+ if (!href.startsWith('#') &&
+ !RegExp(r'\.(jpe?g|png|webp|gif|avif)(\?|$)', caseSensitive: false).hasMatch(href)) {
return;
}
break;
}
}
final url = base.resolve(raw).toString();
- if (url.startsWith('http')) add(BlockType.image, url);
+ if (!url.startsWith('http') || !add(BlockType.image, url)) return;
+ // Comics such as xkcd put a last joke in the title text.
+ final title = _clean(img.attributes['title'] ?? '');
+ if (title.length >= 20 && title != _clean(img.attributes['alt'] ?? '')) {
+ add(BlockType.caption, title);
+ }
}
}
diff --git a/lib/services/claude_summarizer.dart b/lib/services/claude_summarizer.dart
index 4a919c4..08ca805 100644
--- a/lib/services/claude_summarizer.dart
+++ b/lib/services/claude_summarizer.dart
@@ -5,7 +5,7 @@ import 'package:http/http.dart' as http;
import '../models/article.dart';
import '../models/briefing.dart';
-import '../models/news_source.dart';
+import '../models/source.dart';
import 'summarizer.dart';
/// Calls the Claude Messages API directly over HTTP (there is no official
diff --git a/lib/services/feed_parser.dart b/lib/services/feed_parser.dart
index 00091c5..7eac143 100644
--- a/lib/services/feed_parser.dart
+++ b/lib/services/feed_parser.dart
@@ -3,13 +3,13 @@ import 'package:html/parser.dart' as html_parser;
import 'package:xml/xml.dart';
import '../models/article.dart';
-import '../models/news_source.dart';
+import '../models/source.dart';
/// Parses RSS 2.0, RSS 1.0 (RDF) and Atom feeds into [Article]s.
class FeedParser {
static const excerptLength = 400;
- static List parse(String body, NewsSource source) {
+ static List parse(String body, Source source) {
final doc = XmlDocument.parse(body);
final root = doc.rootElement;
if (root.name.local == 'feed') {
@@ -27,7 +27,7 @@ class FeedParser {
.toList();
}
- static Article? _parseRssItem(XmlElement item, NewsSource source) {
+ static Article? _parseRssItem(XmlElement item, Source source) {
final title = htmlToText(_text(item, 'title'));
final link = _text(item, 'link').trim().isNotEmpty
? _text(item, 'link').trim()
@@ -53,7 +53,7 @@ class FeedParser {
);
}
- static Article? _parseAtomEntry(XmlElement entry, NewsSource source) {
+ static Article? _parseAtomEntry(XmlElement entry, Source source) {
final title = htmlToText(_text(entry, 'title'));
final links = entry.childElements.where((e) => e.name.local == 'link');
final link = links
diff --git a/lib/services/feed_service.dart b/lib/services/feed_service.dart
index 7d4c631..e3af97c 100644
--- a/lib/services/feed_service.dart
+++ b/lib/services/feed_service.dart
@@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../models/article.dart';
-import '../models/news_source.dart';
+import '../models/source.dart';
import 'feed_parser.dart';
class FeedResult {
@@ -23,14 +23,15 @@ class FeedService {
static const _timeout = Duration(seconds: 20);
- /// Fetches all [sources] in parallel. 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 maxAge,
+ List sources, {
+ required Duration Function(Source source) windowOf,
String corsProxy = '',
}) async {
final errors = {};
@@ -43,12 +44,15 @@ class FeedService {
}
}));
- final cutoff = DateTime.now().toUtc().subtract(maxAge);
+ final now = DateTime.now().toUtc();
final seen = {};
final articles = [
- for (final list in results)
+ for (final (i, list) in results.indexed)
for (final a in list)
- if ((a.published == null || a.published!.isAfter(cutoff)) && seen.add(a.id)) a,
+ if ((a.published == null ||
+ a.published!.isAfter(now.subtract(windowOf(sources[i])))) &&
+ seen.add(a.id))
+ a,
];
articles.sort((a, b) {
final pa = a.published, pb = b.published;
@@ -60,7 +64,7 @@ class FeedService {
return FeedResult(articles, errors);
}
- Future> fetchSource(NewsSource source, {String corsProxy = ''}) async {
+ Future> fetchSource(Source source, {String corsProxy = ''}) async {
final url = kIsWeb && corsProxy.isNotEmpty
? '$corsProxy${Uri.encodeComponent(source.feedUrl)}'
: source.feedUrl;
diff --git a/lib/services/n8n_summarizer.dart b/lib/services/n8n_summarizer.dart
index 983629b..a5ebf6f 100644
--- a/lib/services/n8n_summarizer.dart
+++ b/lib/services/n8n_summarizer.dart
@@ -4,7 +4,7 @@ import 'package:http/http.dart' as http;
import '../models/article.dart';
import '../models/briefing.dart';
-import '../models/news_source.dart';
+import '../models/source.dart';
import 'summarizer.dart';
/// Delegates summarizing to an existing n8n workflow via a Webhook trigger.
diff --git a/lib/services/paywall_service.dart b/lib/services/paywall_service.dart
index c4d9e7d..0eadb8e 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 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
/// 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:
+ /// `Read more
`, as the very end. Free posts
+ /// end with their own text, even when they ask readers to subscribe.
+ static final _substackCut = RegExp(
+ r'\s*\s*Read more\s* \s*
\s*$',
+ caseSensitive: false,
+ );
+
static final _marker =
RegExp(r'''"isAccessibleForFree"\s*:\s*"?(true|false)"?''', caseSensitive: false);
@@ -41,7 +51,27 @@ 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) {
+ final readMore = _substackCut.firstMatch(a.contentHtml)?.group(1);
+ return readMore != null && _samePost(readMore, a.link);
+ }
+
+ /// Whether two links point at the same post. Only host and path count:
+ /// scheme, host case, a trailing slash, the query (tracking parameters)
+ /// and the fragment don't.
+ static bool _samePost(String a, String b) {
+ String? key(String link) {
+ final uri = Uri.tryParse(link.trim());
+ if (uri == null || uri.host.isEmpty) return null;
+ return '${uri.host.toLowerCase()}${uri.path.replaceFirst(RegExp(r'/+$'), '')}';
+ }
+
+ final keyA = key(a);
+ return keyA != null && keyA == key(b);
+ }
bool isSponsored(Article a) => sponsoredTitle.hasMatch(a.title);
diff --git a/lib/services/storage.dart b/lib/services/storage.dart
index dab305a..319df07 100644
--- a/lib/services/storage.dart
+++ b/lib/services/storage.dart
@@ -9,7 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/app_settings.dart';
import '../models/briefing.dart';
-import '../models/news_source.dart';
+import '../models/source.dart';
/// Persists settings, source selection and the last briefing.
/// The Claude API key goes to the platform keystore, everything else to
@@ -74,15 +74,22 @@ class Storage {
Future saveEnabledSourceIds(Set ids) =>
_prefs.setStringList('enabled_sources', ids.toList());
- List loadCustomSources() {
+ /// Curated sources the app has already offered, so each one that is on by
+ /// default switches on only once. Null means "never saved".
+ Set? loadSeenSourceIds() => _prefs.getStringList('seen_sources')?.toSet();
+
+ Future saveSeenSourceIds(Set ids) =>
+ _prefs.setStringList('seen_sources', ids.toList());
+
+ List loadCustomSources() {
final raw = _prefs.getString('custom_sources');
if (raw == null) return [];
return (jsonDecode(raw) as List)
- .map((j) => NewsSource.custom(j as Map))
+ .map((j) => Source.custom(j as Map))
.toList();
}
- Future saveCustomSources(List sources) => _prefs.setString(
+ Future saveCustomSources(List sources) => _prefs.setString(
'custom_sources', jsonEncode(sources.map((s) => s.toJson()).toList()));
/// Article URL -> whether its page said it's paid, and when that was checked.
diff --git a/lib/services/summarizer.dart b/lib/services/summarizer.dart
index 2a0760a..efc484c 100644
--- a/lib/services/summarizer.dart
+++ b/lib/services/summarizer.dart
@@ -1,6 +1,6 @@
import '../models/article.dart';
import '../models/briefing.dart';
-import '../models/news_source.dart';
+import '../models/source.dart';
class SummarizerException implements Exception {
SummarizerException(this.message);
diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart
index ae25723..111597a 100644
--- a/lib/state/app_state.dart
+++ b/lib/state/app_state.dart
@@ -6,7 +6,7 @@ import '../data/default_sources.dart';
import '../models/app_settings.dart';
import '../models/article.dart';
import '../models/briefing.dart';
-import '../models/news_source.dart';
+import '../models/source.dart';
import '../services/article_extractor.dart';
import '../services/claude_summarizer.dart';
import '../services/consent_service.dart';
@@ -29,8 +29,15 @@ class SummaryState {
}
class AppState extends ChangeNotifier {
- AppState(this._storage, {FeedService? feedService, ArticleExtractor? extractor})
- : _feeds = feedService ?? FeedService(),
+ AppState(
+ this._storage, {
+ FeedService? feedService,
+ ArticleExtractor? extractor,
+ List curatedSources = defaultSources,
+ Summarizer? summarizer,
+ }) : _feeds = feedService ?? FeedService(),
+ _curated = curatedSources,
+ _summarizerOverride = summarizer,
consent = ConsentService(),
paywall = PaywallService(_storage) {
_extractor = extractor ?? ArticleExtractor(consent: consent);
@@ -38,6 +45,12 @@ class AppState extends ChangeNotifier {
final Storage _storage;
final FeedService _feeds;
+
+ /// The curated catalog; [defaultSources] outside of tests.
+ final List _curated;
+
+ /// Used instead of the AI set up in [settings]; for tests.
+ final Summarizer? _summarizerOverride;
late final ArticleExtractor _extractor;
final PaywallService paywall;
@@ -45,10 +58,10 @@ class AppState extends ChangeNotifier {
final ConsentService consent;
AppSettings settings = const AppSettings();
- List customSources = [];
+ List customSources = [];
Set enabledSourceIds = {};
- /// Everything the feeds returned; see [visibleArticles] for what is shown.
+ /// Everything the feeds returned; see [articlesOf] for what is shown.
List articles = [];
Map feedErrors = {};
bool loading = false;
@@ -69,21 +82,37 @@ class AppState extends ChangeNotifier {
(settings.hidePaid && paywall.isPaid(a)) ||
(settings.hideSponsored && paywall.isSponsored(a));
- /// Articles after the paid/sponsored filters. The feed and the briefing use these.
- List get visibleArticles => articles.where((a) => !_isHidden(a)).toList();
+ /// 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);
- int get hiddenCount => articles.where(_isHidden).length;
+ /// Articles from Sources of [kind] after the paid/sponsored filters. Today
+ /// uses News, the Learn tab the other kinds.
+ List articlesOf(SourceKind kind) => _articlesWhere((k) => k == kind).toList();
- List get allSources => [...defaultSources, ...customSources];
+ /// Articles from Sources of [kind] that the paid/sponsored filters hide.
+ int hiddenCountOf(SourceKind kind) => _articlesWhere((k) => k == kind, hidden: true).length;
- List get enabledSources =>
+ /// 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) =>
+ kind.fixedWindow ?? Duration(hours: settings.maxAgeHours);
+
+ List get allSources => [..._curated, ...customSources];
+
+ List get enabledSources =>
allSources.where((s) => enabledSourceIds.contains(s.id)).toList();
Future 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();
@@ -95,7 +124,7 @@ class AppState extends ChangeNotifier {
notifyListeners();
final result = await _feeds.fetchAll(
enabledSources,
- maxAge: Duration(hours: settings.maxAgeHours),
+ windowOf: (source) => windowOf(source.kind),
corsProxy: settings.corsProxy,
);
articles = result.articles;
@@ -111,7 +140,28 @@ class AppState extends ChangeNotifier {
// --- Sources -------------------------------------------------------------
- Future setSourceEnabled(NewsSource source, bool enabled) async {
+ /// 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 _enableNewDefaultSources() async {
+ final saved = _storage.loadEnabledSourceIds();
+ final seen = _storage.loadSeenSourceIds() ??
+ (saved == null ? const {} : 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 setSourceEnabled(Source source, bool enabled) async {
enabled ? enabledSourceIds.add(source.id) : enabledSourceIds.remove(source.id);
notifyListeners();
await _storage.saveEnabledSourceIds(enabledSourceIds);
@@ -123,14 +173,16 @@ class AppState extends ChangeNotifier {
required String name,
required String feedUrl,
required SourceLanguage language,
+ SourceKind kind = SourceKind.news,
}) async {
final uri = Uri.tryParse(feedUrl);
- final source = NewsSource(
+ final source = Source(
id: 'custom-${DateTime.now().millisecondsSinceEpoch}',
name: name,
feedUrl: feedUrl,
homepage: uri == null ? '' : '${uri.scheme}://${uri.host}',
language: language,
+ kind: kind,
isCustom: true,
);
try {
@@ -147,7 +199,7 @@ class AppState extends ChangeNotifier {
return null;
}
- Future removeCustomSource(NewsSource source) async {
+ Future removeCustomSource(Source source) async {
customSources = customSources.where((s) => s.id != source.id).toList();
enabledSourceIds.remove(source.id);
await _storage.saveCustomSources(customSources);
@@ -188,7 +240,7 @@ class AppState extends ChangeNotifier {
}
final source = allSources.firstWhere(
(s) => s.name == sourceName,
- orElse: () => NewsSource(
+ orElse: () => Source(
id: sourceName,
name: sourceName,
feedUrl: '',
@@ -201,7 +253,7 @@ class AppState extends ChangeNotifier {
// --- AI ------------------------------------------------------------------
- Summarizer? get _summarizer => switch (settings.aiProvider) {
+ Summarizer? get _summarizer => _summarizerOverride ?? switch (settings.aiProvider) {
_ when !settings.aiConfigured => null,
AiProvider.claude => ClaudeSummarizer(
apiKey: settings.claudeApiKey, model: settings.claudeModel),
@@ -245,7 +297,7 @@ class AppState extends ChangeNotifier {
Future generateBriefing() async {
final summarizer = _summarizer;
if (summarizer == null || briefingLoading) return;
- final articles = visibleArticles;
+ 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 65dbbb9..1dc5709 100644
--- a/lib/ui/article_screen.dart
+++ b/lib/ui/article_screen.dart
@@ -275,12 +275,20 @@ class _ReaderView extends StatelessWidget {
builder: (context, snapshot) {
final loaded = snapshot.data;
var blocks = loaded?.blocks ?? const [];
+ 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 (article.imageUrl != null) {
+ if (banner != null) {
blocks = blocks.skipWhile((b) => b.type == BlockType.image).toList();
}
+ if (pictureLed &&
+ article.imageUrl != null &&
+ !blocks.any((b) => b.type == BlockType.image)) {
+ blocks = [ReaderBlock(BlockType.image, article.imageUrl!), ...blocks];
+ }
final isFull = loaded != null &&
- loaded.plainText.length >= max(400, article.excerpt.length * 2);
+ (loaded.plainText.length >= max(400, article.excerpt.length * 2) ||
+ (pictureLed && blocks.any((b) => b.type == BlockType.image)));
// Skip the lead if the body already starts with it.
final firstText = blocks.where((b) => b.type == BlockType.paragraph).firstOrNull;
final showLead = article.excerpt.isNotEmpty &&
@@ -291,9 +299,9 @@ class _ReaderView extends StatelessWidget {
return ListView(
padding: const EdgeInsets.only(bottom: 48),
children: [
- if (article.imageUrl != null)
+ if (banner != null)
Image.network(
- article.imageUrl!,
+ banner,
fit: BoxFit.cover,
webHtmlElementStrategy: WebHtmlElementStrategy.prefer,
// Take up space only once the image has loaded, so a broken
@@ -336,7 +344,7 @@ class _ReaderView extends StatelessWidget {
),
if (isFull)
for (final block in blocks)
- if (block.text != article.imageUrl) _BlockView(block: block),
+ if (block.text != banner) _BlockView(block: block),
if (loaded != null) ...[
const SizedBox(height: 24),
const Divider(),
diff --git a/lib/ui/briefing_screen.dart b/lib/ui/briefing_screen.dart
index f9a01aa..32fa414 100644
--- a/lib/ui/briefing_screen.dart
+++ b/lib/ui/briefing_screen.dart
@@ -59,7 +59,7 @@ class BriefingScreen extends StatelessWidget {
if (state.briefingLoading) {
return EmptyState(
icon: Icons.hourglass_top,
- title: 'Reading ${state.visibleArticles.length} headlines…',
+ title: 'Reading ${state.briefingArticles.length} headlines…',
message: 'This usually takes 10-60 seconds.',
action: const SizedBox(width: 160, child: LinearProgressIndicator()),
);
@@ -69,7 +69,7 @@ class BriefingScreen extends StatelessWidget {
icon: const Icon(Icons.auto_awesome),
label: Text(state.loading
? 'Waiting for feeds…'
- : 'Generate from ${state.visibleArticles.length} articles'),
+ : 'Generate from ${state.briefingArticles.length} articles'),
);
if (briefing == null) {
return EmptyState(
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 2440006..058fa4d 100644
--- a/lib/ui/feed_screen.dart
+++ b/lib/ui/feed_screen.dart
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import '../models/article.dart';
-import '../models/news_source.dart';
+import '../models/source.dart';
import '../state/app_state.dart';
import 'article_screen.dart';
import 'common.dart';
@@ -25,13 +25,12 @@ class _FeedScreenState extends State {
listenable: widget.state,
builder: (context, _) {
final state = widget.state;
- final all = state.visibleArticles;
+ final all = state.articlesOf(SourceKind.news);
final visible = all
.where((a) => _language == null || a.source.language == _language)
.where((a) => _sourceId == null || a.source.id == _sourceId)
.toList();
final sourcesInFeed = {for (final a in all) a.source.id: a.source};
- final hidden = state.hiddenCount;
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),
),
);
},
@@ -113,7 +97,7 @@ class _FeedScreenState extends State {
);
}
- Widget _filterBar(List sources) {
+ Widget _filterBar(List sources) {
sources.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return SizedBox(
height: 52,
@@ -157,7 +141,7 @@ class _FeedScreenState extends State {
);
}
- static const _placeholder = NewsSource(
+ static const _placeholder = Source(
id: '', name: 'All sources', feedUrl: '', homepage: '', language: SourceLanguage.en);
}
diff --git a/lib/ui/home_shell.dart b/lib/ui/home_shell.dart
index 386bcf3..da21a4b 100644
--- a/lib/ui/home_shell.dart
+++ b/lib/ui/home_shell.dart
@@ -3,9 +3,13 @@ import 'package:flutter/material.dart';
import '../state/app_state.dart';
import 'briefing_screen.dart';
import 'feed_screen.dart';
+import 'learn_screen.dart';
import 'settings_screen.dart';
import 'sources_screen.dart';
+/// The tabs in the order they appear in the navigation bar.
+enum _Tab { today, briefing, learn, sources, settings }
+
class HomeShell extends StatefulWidget {
const HomeShell({super.key, required this.state});
@@ -16,20 +20,20 @@ class HomeShell extends StatefulWidget {
}
class _HomeShellState extends State {
- int _index = 0;
+ _Tab _tab = _Tab.today;
bool _sourcesChanged = false;
- void _select(int index) {
+ void _select(_Tab tab) {
// A text field left focused in Settings would otherwise get focus back
// (and open the keyboard) when returning from an article.
FocusManager.instance.primaryFocus?.unfocus();
// Source toggles only take effect on the next refresh; do it when the
// user leaves the Sources tab.
- if (_index == 2 && index != 2 && _sourcesChanged) {
+ if (_tab == _Tab.sources && tab != _Tab.sources && _sourcesChanged) {
_sourcesChanged = false;
widget.state.refresh();
}
- setState(() => _index = index);
+ setState(() => _tab = tab);
}
@override
@@ -37,20 +41,27 @@ class _HomeShellState extends State {
final state = widget.state;
return Scaffold(
body: IndexedStack(
- index: _index,
+ index: _tab.index,
children: [
- FeedScreen(state: state),
- BriefingScreen(state: state, openSettings: () => _select(3)),
- SourcesScreen(state: state, onChanged: () => _sourcesChanged = true),
- SettingsScreen(state: state),
- ].indexed
+ for (final tab in _Tab.values)
// Hidden tabs stay alive but must not hold or regain focus.
- .map((e) => ExcludeFocus(excluding: e.$1 != _index, child: e.$2))
- .toList(),
+ ExcludeFocus(
+ excluding: tab != _tab,
+ child: switch (tab) {
+ _Tab.today => FeedScreen(state: state),
+ _Tab.briefing => BriefingScreen(
+ state: state, openSettings: () => _select(_Tab.settings)),
+ _Tab.learn => LearnScreen(state: state),
+ _Tab.sources =>
+ SourcesScreen(state: state, onChanged: () => _sourcesChanged = true),
+ _Tab.settings => SettingsScreen(state: state),
+ },
+ ),
+ ],
),
bottomNavigationBar: NavigationBar(
- selectedIndex: _index,
- onDestinationSelected: _select,
+ selectedIndex: _tab.index,
+ onDestinationSelected: (i) => _select(_Tab.values[i]),
destinations: const [
NavigationDestination(
icon: Icon(Icons.newspaper_outlined),
@@ -60,6 +71,10 @@ class _HomeShellState extends State {
icon: Icon(Icons.auto_awesome_outlined),
selectedIcon: Icon(Icons.auto_awesome),
label: 'Briefing'),
+ NavigationDestination(
+ icon: Icon(Icons.school_outlined),
+ selectedIcon: Icon(Icons.school),
+ label: 'Learn'),
NavigationDestination(
icon: Icon(Icons.rss_feed_outlined),
selectedIcon: Icon(Icons.rss_feed),
diff --git a/lib/ui/learn_screen.dart b/lib/ui/learn_screen.dart
new file mode 100644
index 0000000..3f7026b
--- /dev/null
+++ b/lib/ui/learn_screen.dart
@@ -0,0 +1,81 @@
+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
+/// section per kind, each with its own time window.
+class LearnScreen extends StatelessWidget {
+ const LearnScreen({super.key, required this.state});
+
+ final AppState state;
+
+ /// The sections, top to bottom.
+ static const kinds = [SourceKind.learning, SourceKind.fun];
+
+ @override
+ Widget build(BuildContext context) {
+ return ListenableBuilder(
+ listenable: state,
+ builder: (context, _) => Scaffold(
+ appBar: AppBar(
+ title: const Text('Learn'),
+ actions: [RefreshButton(state: state)],
+ ),
+ body: RefreshableList(
+ state: state,
+ list: ListView(
+ padding: const EdgeInsets.fromLTRB(12, 0, 12, 24),
+ children: [for (final kind in kinds) ..._kindSection(context, kind)],
+ ),
+ ),
+ ),
+ );
+ }
+
+ List _kindSection(BuildContext context, SourceKind kind) {
+ final theme = Theme.of(context);
+ final muted = theme.colorScheme.onSurfaceVariant;
+ final articles = state.articlesOf(kind);
+ final hasSources = state.enabledSources.any((s) => s.kind == kind);
+ return [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(kind.label,
+ style: theme.textTheme.titleMedium?.copyWith(color: theme.colorScheme.primary)),
+ Text(
+ articleCountLine(state, kind),
+ style: theme.textTheme.bodySmall?.copyWith(color: muted),
+ ),
+ ],
+ ),
+ ),
+ if (articles.isEmpty && !state.loading)
+ Padding(
+ padding: const EdgeInsets.fromLTRB(4, 8, 4, 16),
+ child: Text(
+ hasSources
+ ? 'Nothing new in this window. Pull down to refresh.'
+ : 'No ${kind.label} sources enabled. Turn some on in the Sources tab.',
+ style: theme.textTheme.bodyMedium?.copyWith(color: muted),
+ ),
+ ),
+ for (final article in articles)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: ArticleCard(
+ article: article,
+ hasSummary: state.summaries[article.id]?.text != null,
+ isPaid: state.paywall.isPaid(article),
+ onTap: () => openArticle(context, state, article),
+ ),
+ ),
+ ];
+ }
+}
diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart
index ba1be46..72e82e8 100644
--- a/lib/ui/settings_screen.dart
+++ b/lib/ui/settings_screen.dart
@@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../models/app_settings.dart';
-import '../models/news_source.dart';
+import '../models/source.dart';
import '../state/app_state.dart';
import 'common.dart';
diff --git a/lib/ui/sources_screen.dart b/lib/ui/sources_screen.dart
index a7927c3..42bc160 100644
--- a/lib/ui/sources_screen.dart
+++ b/lib/ui/sources_screen.dart
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
-import '../models/news_source.dart';
+import '../models/source.dart';
import '../state/app_state.dart';
class SourcesScreen extends StatelessWidget {
@@ -40,29 +40,7 @@ class SourcesScreen extends StatelessWidget {
body: ListView(
padding: const EdgeInsets.only(bottom: 96),
children: [
- for (final lang in SourceLanguage.values) ...[
- Padding(
- padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
- child: Text('${lang.flag} ${lang.label}',
- style: theme.textTheme.titleSmall
- ?.copyWith(color: theme.colorScheme.primary)),
- ),
- for (final source in state.allSources.where((s) => s.language == lang))
- _SourceTile(
- source: source,
- enabled: state.enabledSourceIds.contains(source.id),
- onToggle: (v) {
- state.setSourceEnabled(source, v);
- onChanged();
- },
- onDelete: source.isCustom
- ? () {
- state.removeCustomSource(source);
- onChanged();
- }
- : null,
- ),
- ],
+ for (final kind in SourceKind.values) ..._kindSection(context, kind),
],
),
);
@@ -70,6 +48,44 @@ class SourcesScreen extends StatelessWidget {
);
}
+ /// One section per kind, split by language. Kinds and languages without
+ /// Sources are left out instead of showing an empty header.
+ List _kindSection(BuildContext context, SourceKind kind) {
+ final theme = Theme.of(context);
+ final sources = state.allSources.where((s) => s.kind == kind).toList();
+ if (sources.isEmpty) return const [];
+ return [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 24, 16, 0),
+ child: Text(kind.label, style: theme.textTheme.titleMedium),
+ ),
+ for (final lang in SourceLanguage.values)
+ if (sources.any((s) => s.language == lang)) ...[
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
+ child: Text('${lang.flag} ${lang.label}',
+ style: theme.textTheme.titleSmall
+ ?.copyWith(color: theme.colorScheme.primary)),
+ ),
+ for (final source in sources.where((s) => s.language == lang))
+ _SourceTile(
+ source: source,
+ enabled: state.enabledSourceIds.contains(source.id),
+ onToggle: (v) {
+ state.setSourceEnabled(source, v);
+ onChanged();
+ },
+ onDelete: source.isCustom
+ ? () {
+ state.removeCustomSource(source);
+ onChanged();
+ }
+ : null,
+ ),
+ ],
+ ];
+ }
+
Future _addFeed(BuildContext context) async {
final added = await showDialog(
context: context,
@@ -93,7 +109,7 @@ class _SourceTile extends StatelessWidget {
this.onDelete,
});
- final NewsSource source;
+ final Source source;
final bool enabled;
final ValueChanged onToggle;
final VoidCallback? onDelete;
@@ -134,6 +150,7 @@ class _AddFeedDialogState extends State<_AddFeedDialog> {
final _name = TextEditingController();
final _url = TextEditingController();
SourceLanguage _language = SourceLanguage.de;
+ SourceKind _kind = SourceKind.news;
bool _busy = false;
String? _error;
@@ -156,7 +173,7 @@ class _AddFeedDialogState extends State<_AddFeedDialog> {
_error = null;
});
final error = await widget.state
- .addCustomSource(name: name, feedUrl: url, language: _language);
+ .addCustomSource(name: name, feedUrl: url, language: _language, kind: _kind);
if (!mounted) return;
if (error == null) {
Navigator.pop(context, true);
@@ -199,6 +216,15 @@ class _AddFeedDialogState extends State<_AddFeedDialog> {
selected: {_language},
onSelectionChanged: (s) => setState(() => _language = s.first),
),
+ const SizedBox(height: 12),
+ SegmentedButton(
+ segments: [
+ for (final k in SourceKind.values)
+ ButtonSegment(value: k, label: Text(k.label)),
+ ],
+ selected: {_kind},
+ onSelectionChanged: (s) => setState(() => _kind = s.first),
+ ),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
diff --git a/test/article_extractor_test.dart b/test/article_extractor_test.dart
index 252a73a..fafc16c 100644
--- a/test/article_extractor_test.dart
+++ b/test/article_extractor_test.dart
@@ -1,7 +1,7 @@
import 'dart:convert';
import 'package:daily_tech_news/models/article.dart';
-import 'package:daily_tech_news/models/news_source.dart';
+import 'package:daily_tech_news/models/source.dart';
import 'package:daily_tech_news/services/article_extractor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
@@ -88,7 +88,7 @@ void main() {
title: 'Story',
link: 'https://www.golem.de/news/story.html',
excerpt: 'Teaser',
- source: NewsSource(
+ source: Source(
id: 'golem', name: 'Golem.de', feedUrl: '', homepage: '', language: SourceLanguage.de),
);
final content = await extractor.load(article);
@@ -102,8 +102,86 @@ void main() {
isFragment: true);
expect(content.blocks, hasLength(2));
});
+
+ test('an xkcd page gives the comic and its title text, without the page around it', () {
+ final content = ArticleExtractor.parse(xkcdPage, Uri.parse('https://xkcd.com/3312/'));
+
+ expect(content.blocks.map((b) => (b.type, b.text)), [
+ (BlockType.image, 'https://imgs.xkcd.com/comics/momentum_demonstration.png'),
+ (BlockType.caption, xkcdTitleText),
+ ]);
+ });
+
+ test("The Daily WTF's Error'd screenshots show between their stories", () {
+ final content = ArticleExtractor.parse(
+ errordPage, Uri.parse('https://thedailywtf.com/articles/scrollpocalypse'));
+
+ expect(content.blocks.map((b) => b.type), [
+ BlockType.paragraph,
+ BlockType.image,
+ BlockType.paragraph,
+ BlockType.image,
+ ]);
+ expect(content.blocks[1].text, 'https://cdn.example.com/errord/first.png');
+ });
}
+const xkcdTitleText = '"I\'m doing really well on the Hertzsprung-Russell academic diagram, '
+ 'which has spectral class on one axis and Rate My Professors rating on the other."';
+
+/// Trimmed from https://xkcd.com/3312/.
+const xkcdPage = '''
+
+xkcd: Momentum Demonstration
+
+
+
+
+''';
+
+/// Trimmed from https://thedailywtf.com/articles/scrollpocalypse.
+const errordPage = '''
+
+
+
+
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/claude_summarizer_test.dart b/test/claude_summarizer_test.dart
index 8fe8055..10e6eb8 100644
--- a/test/claude_summarizer_test.dart
+++ b/test/claude_summarizer_test.dart
@@ -1,14 +1,14 @@
import 'dart:convert';
import 'package:daily_tech_news/models/article.dart';
-import 'package:daily_tech_news/models/news_source.dart';
+import 'package:daily_tech_news/models/source.dart';
import 'package:daily_tech_news/services/claude_summarizer.dart';
import 'package:daily_tech_news/services/summarizer.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
-const _source = NewsSource(
+const _source = Source(
id: 'verge',
name: 'The Verge',
feedUrl: 'https://example.com/feed',
diff --git a/test/comic_reader_test.dart b/test/comic_reader_test.dart
new file mode 100644
index 0000000..ab0dc32
--- /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/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/consent_service_test.dart b/test/consent_service_test.dart
index edca0e7..861d94c 100644
--- a/test/consent_service_test.dart
+++ b/test/consent_service_test.dart
@@ -1,7 +1,7 @@
import 'dart:convert';
import 'package:daily_tech_news/models/article.dart';
-import 'package:daily_tech_news/models/news_source.dart';
+import 'package:daily_tech_news/models/source.dart';
import 'package:daily_tech_news/services/article_extractor.dart';
import 'package:daily_tech_news/services/consent_service.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -50,7 +50,7 @@ const _article = Article(
title: 'Story',
link: _articleUrl,
excerpt: 'Teaser',
- source: NewsSource(
+ source: Source(
id: 'golem', name: 'Golem.de', feedUrl: '', homepage: '', language: SourceLanguage.de),
);
diff --git a/test/default_sources_test.dart b/test/default_sources_test.dart
new file mode 100644
index 0000000..262b64b
--- /dev/null
+++ b/test/default_sources_test.dart
@@ -0,0 +1,85 @@
+import 'package:daily_tech_news/data/default_sources.dart';
+import 'package:daily_tech_news/models/source.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+Source _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.kind == SourceKind.news) 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('the Learning sources are the curated set, all English, with the agreed defaults', () {
+ final learning = {
+ for (final s in defaultSources)
+ if (s.kind == SourceKind.learning) s.name: (s.feedUrl, s.language, s.enabledByDefault),
+ };
+
+ const en = SourceLanguage.en;
+ expect(learning, {
+ 'ByteByteGo': ('https://blog.bytebytego.com/feed', en, true),
+ 'System Design One': ('https://newsletter.systemdesign.one/feed', en, true),
+ 'The Pragmatic Engineer (blog)': ('https://blog.pragmaticengineer.com/rss/', en, true),
+ 'Martin Fowler': ('https://martinfowler.com/feed.atom', en, true),
+ 'Julia Evans': ('https://jvns.ca/atom.xml', en, true),
+ 'Dan Luu': ('https://danluu.com/atom.xml', en, true),
+ 'The Cloudflare Blog': ('https://blog.cloudflare.com/rss/', en, true),
+ 'Netflix Technology Blog': ('https://netflixtechblog.com/feed', en, true),
+ 'Engineering at Meta': ('https://engineering.fb.com/feed/', en, true),
+ 'GitHub Blog: Engineering': ('https://github.blog/engineering/feed/', en, true),
+ 'Latent Space': ('https://www.latent.space/feed', en, true),
+ 'Import AI': ('https://importai.substack.com/feed', en, true),
+ 'Interconnects': ('https://www.interconnects.ai/feed', en, true),
+ 'Ahead of AI': ('https://magazine.sebastianraschka.com/feed', en, true),
+ 'Lilian Weng': ('https://lilianweng.github.io/index.xml', en, true),
+ 'InfoQ': ('https://feed.infoq.com/', en, false),
+ });
+ });
+
+ test('every Learning source says what it is for and links its homepage', () {
+ for (final s in defaultSources.where((s) => s.kind == SourceKind.learning)) {
+ expect(s.description, isNotEmpty, reason: s.name);
+ expect(Uri.parse(s.homepage).isScheme('https'), isTrue, reason: s.name);
+ }
+ });
+
+ test('every Source has its own id', () {
+ final ids = defaultSources.map((s) => s.id).toList();
+
+ expect(ids.toSet().length, ids.length);
+ });
+}
diff --git a/test/feed_parser_test.dart b/test/feed_parser_test.dart
index ac06f01..10e5c26 100644
--- a/test/feed_parser_test.dart
+++ b/test/feed_parser_test.dart
@@ -1,8 +1,8 @@
-import 'package:daily_tech_news/models/news_source.dart';
+import 'package:daily_tech_news/models/source.dart';
import 'package:daily_tech_news/services/feed_parser.dart';
import 'package:flutter_test/flutter_test.dart';
-const _source = NewsSource(
+const _source = Source(
id: 'test',
name: 'Test',
feedUrl: 'https://example.com/feed',
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-scratch Sat, 15 Aug 2026 11:54:24 GMT Substack 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.
+
+
+ Read more
+
+
+ ]]>
+
+ https://magazine.sebastianraschka.com/p/controlling-reasoning-effort-in-llmshttps://magazine.sebastianraschka.com/p/controlling-reasoning-effort-in-llms Sat, 18 Jul 2026 11:16:09 GMT It 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-and Wed, 09 Sep 2026 11:14:25 GMT A 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-hacks Sun, 09 Aug 2026 14:57:11 GMT The 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:
+
+
+ Read more
+
+
+ ]]>
+
+ https://www.interconnects.ai/p/when-will-average-people-feel-aishttps://www.interconnects.ai/p/when-will-average-people-feel-ais Wed, 09 Sep 2026 11:01:23 GMT Housekeeping: 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.
]]>
+
diff --git a/test/fun_section_test.dart b/test/fun_section_test.dart
new file mode 100644
index 0000000..27096df
--- /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/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/learn_tab_test.dart b/test/learn_tab_test.dart
new file mode 100644
index 0000000..690cf4a
--- /dev/null
+++ b/test/learn_tab_test.dart
@@ -0,0 +1,92 @@
+import 'package:daily_tech_news/models/source.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/home_shell.dart';
+import 'package:daily_tech_news/ui/learn_screen.dart';
+import 'package:daily_tech_news/ui/settings_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';
+
+void main() {
+ final verge = testSource('verge', SourceKind.news);
+ final bytebytego = testSource('bytebytego', SourceKind.learning);
+ final feeds = {
+ verge.feedUrl: [('News from this morning', const Duration(hours: 2))],
+ bytebytego.feedUrl: [('Explainer from 13 days ago', const Duration(days: 13)),
+ ('Explainer from 2 days ago', const Duration(days: 2))],
+ };
+
+ setUp(() => SharedPreferences.setMockInitialValues({}));
+
+ Future launch(WidgetTester tester, [FakeFeeds? feedService]) async =>
+ (await tester.runAsync(() => launchApp([verge, bytebytego],
+ feedService: feedService ?? FakeFeeds(feeds))))!;
+
+ Finder tab(String label) =>
+ find.descendant(of: find.byType(NavigationBar), matching: find.text(label));
+
+ testWidgets('the Learn tab lists Learning articles newest first, without News',
+ (tester) async {
+ final state = await launch(tester);
+
+ await tester.pumpWidget(MaterialApp(home: LearnScreen(state: state)));
+
+ final newer = find.text('Explainer from 2 days ago');
+ final older = find.text('Explainer from 13 days ago');
+ expect(newer, findsOneWidget);
+ expect(older, findsOneWidget);
+ expect(tester.getTopLeft(newer).dy, lessThan(tester.getTopLeft(older).dy));
+ expect(find.text('News from this morning'), findsNothing);
+ });
+
+ testWidgets('Today lists News without Learning 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('Explainer from 2 days ago'), findsNothing);
+ });
+
+ testWidgets('the tabs are Today, Briefing, Learn, Sources and Settings', (tester) async {
+ final state = await launch(tester);
+
+ await tester.pumpWidget(MaterialApp(home: HomeShell(state: state)));
+
+ final labels = ['Today', 'Briefing', 'Learn', 'Sources', 'Settings'];
+ final xs = [for (final label in labels) tester.getCenter(tab(label)).dx];
+ expect(xs, [...xs]..sort());
+ });
+
+ testWidgets('Open Settings on the Briefing tab goes to Settings', (tester) async {
+ final state = await launch(tester);
+ await tester.pumpWidget(MaterialApp(home: HomeShell(state: state)));
+
+ await tester.tap(tab('Briefing'));
+ await tester.pump();
+ await tester.tap(find.text('Open Settings'));
+ await tester.pump();
+
+ expect(find.byType(SettingsScreen), findsOneWidget);
+ });
+
+ 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)));
+ final before = feedService.refreshes;
+
+ await tester.tap(tab('Sources'));
+ await tester.pump();
+ await tester.tap(find.text('verge'));
+ await tester.pump();
+ expect(feedService.refreshes, before);
+
+ await tester.tap(tab('Learn'));
+ await tester.pump();
+ expect(feedService.refreshes, before + 1);
+ });
+}
diff --git a/test/live_feeds_test.dart b/test/live_feeds_test.dart
index d68d734..4f9aae0 100644
--- a/test/live_feeds_test.dart
+++ b/test/live_feeds_test.dart
@@ -2,10 +2,14 @@
import 'dart:io';
import 'package:daily_tech_news/data/default_sources.dart';
+import 'package:daily_tech_news/models/source.dart';
import 'package:daily_tech_news/services/article_extractor.dart';
import 'package:daily_tech_news/services/consent_service.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';
void main() {
final live = Platform.environment['LIVE_FEEDS'] == '1';
@@ -29,6 +33,19 @@ void main() {
expect(failures, isEmpty);
}, skip: live ? false : 'set LIVE_FEEDS=1 to run', timeout: const Timeout(Duration(minutes: 3)));
+ test('a full refresh with all on-by-default Sources stays fast', () async {
+ SharedPreferences.setMockInitialValues({});
+ final state = AppState(await Storage.open(), feedService: FeedService());
+ final clock = Stopwatch()..start();
+ await state.init();
+ clock.stop();
+ // ignore: avoid_print
+ print('${state.enabledSources.length} Sources, ${state.articles.length} articles '
+ '(${state.articlesOf(SourceKind.learning).length} Learning) in '
+ '${clock.elapsedMilliseconds} ms; errors: ${state.feedErrors}');
+ expect(clock.elapsed, lessThan(const Duration(seconds: 15)));
+ }, skip: live ? false : 'set LIVE_FEEDS=1 to run', timeout: const Timeout(Duration(minutes: 1)));
+
test('reader view extracts the article body', () async {
final service = FeedService();
final extractor = ArticleExtractor(consent: ConsentService());
diff --git a/test/paid_substack_posts_test.dart b/test/paid_substack_posts_test.dart
new file mode 100644
index 0000000..b6f6923
--- /dev/null
+++ b/test/paid_substack_posts_test.dart
@@ -0,0 +1,75 @@
+import 'dart:io';
+
+import 'package:daily_tech_news/models/article.dart';
+import 'package:daily_tech_news/models/source.dart';
+import 'package:daily_tech_news/services/feed_parser.dart';
+import 'package:daily_tech_news/services/feed_service.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import 'support/fake_app.dart';
+
+/// Serves saved Substack feeds (trimmed real items) instead of the network,
+/// dated an hour ago so every time window keeps them.
+class _SavedFeeds extends FeedService {
+ _SavedFeeds(this.fixtureByFeedUrl);
+
+ final Map fixtureByFeedUrl;
+
+ @override
+ Future> fetchSource(Source source, {String corsProxy = ''}) async {
+ final xml = File(fixtureByFeedUrl[source.feedUrl]!).readAsStringSync();
+ final anHourAgo = DateTime.now().toUtc().subtract(const Duration(hours: 1));
+ return [
+ for (final a in FeedParser.parse(xml, source))
+ Article(
+ title: a.title,
+ link: a.link,
+ source: a.source,
+ published: anHourAgo,
+ excerpt: a.excerpt,
+ contentHtml: a.contentHtml,
+ ),
+ ];
+ }
+}
+
+List _titles(List articles) => articles.map((a) => a.title).toList();
+
+void main() {
+ final interconnects = testSource('interconnects', SourceKind.learning);
+ final substackNews = testSource('substack-news', SourceKind.news);
+
+ setUp(() => SharedPreferences.setMockInitialValues({}));
+
+ _SavedFeeds savedFeeds() => _SavedFeeds({
+ interconnects.feedUrl: 'test/fixtures/interconnects_feed.xml',
+ substackNews.feedUrl: 'test/fixtures/ahead_of_ai_feed.xml',
+ });
+
+ 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());
+
+ expect(_titles(state.articlesOf(SourceKind.learning)),
+ ['When will average people feel AI’s impact?']);
+ expect(state.hiddenCountOf(SourceKind.learning), 1);
+ expect(_titles(state.articlesOf(SourceKind.news)), [
+ 'Controlling Reasoning Effort in LLMs',
+ 'GPT-6 Astra, Looped Transformers, and Hidden Reasoning',
+ ]);
+ expect(state.hiddenCountOf(SourceKind.news), 1);
+ });
+
+ 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));
+
+ expect(_titles(state.articlesOf(SourceKind.learning)).toSet(),
+ {'Lessons from the hacks', 'When will average people feel AI’s impact?'});
+ expect(state.hiddenCountOf(SourceKind.learning), 0);
+ expect(_titles(state.articlesOf(SourceKind.news)), contains('Building an AI Text Detector From Scratch'));
+ expect(state.hiddenCountOf(SourceKind.news), 0);
+ });
+}
diff --git a/test/paywall_service_test.dart b/test/paywall_service_test.dart
index 64142c1..476c7b2 100644
--- a/test/paywall_service_test.dart
+++ b/test/paywall_service_test.dart
@@ -1,5 +1,8 @@
+import 'dart:io';
+
import 'package:daily_tech_news/models/article.dart';
-import 'package:daily_tech_news/models/news_source.dart';
+import 'package:daily_tech_news/models/source.dart';
+import 'package:daily_tech_news/services/feed_parser.dart';
import 'package:daily_tech_news/services/paywall_service.dart';
import 'package:daily_tech_news/services/storage.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -7,7 +10,7 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
-NewsSource _source({bool checkPage = false}) => NewsSource(
+Source _source({bool checkPage = false}) => Source(
id: 'src',
name: 'Source',
feedUrl: 'https://example.com/feed',
@@ -38,6 +41,46 @@ void main() {
expect(paywall.isSponsored(_article('Anzeigenmarkt bricht ein')), isFalse);
});
+ test('recognizes paid Substack posts by the cut-off content in the feed', () async {
+ final paywall = PaywallService(await _storage(), client: MockClient((_) async => http.Response('', 500)));
+ final posts = {
+ for (final a in FeedParser.parse(
+ File('test/fixtures/ahead_of_ai_feed.xml').readAsStringSync(), _source()))
+ a.title: a,
+ };
+
+ expect(paywall.isPaid(posts['Building an AI Text Detector From Scratch']!), isTrue);
+ expect(paywall.isPaid(posts['Controlling Reasoning Effort in LLMs']!), isFalse,
+ reason: 'a free post asking readers to become paid subscribers');
+ expect(paywall.isPaid(posts['GPT-6 Astra, Looped Transformers, and Hidden Reasoning']!), isFalse,
+ reason: 'a free post ending with the subscribe widget');
+ });
+
+ test('matches the "Read more" link of a paid Substack post to the post despite URL noise',
+ () async {
+ final paywall = PaywallService(await _storage(), client: MockClient((_) async => http.Response('', 500)));
+ Article cutPost({required String link, required String readMore}) => Article(
+ title: 'A paid post',
+ link: link,
+ source: _source(),
+ contentHtml: 'The first paragraph.
Read more
',
+ );
+ 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 = [];
diff --git a/test/source_kinds_test.dart b/test/source_kinds_test.dart
new file mode 100644
index 0000000..352912a
--- /dev/null
+++ b/test/source_kinds_test.dart
@@ -0,0 +1,192 @@
+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';
+
+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);
+ }
+}
+
+List _titles(List articles) => articles.map((a) => a.title).toList();
+
+void main() {
+ final verge = testSource('verge', SourceKind.news);
+ final bytebytego = testSource('bytebytego', SourceKind.learning);
+
+ setUp(() => SharedPreferences.setMockInitialValues({}));
+
+ test('News uses the 24 h setting and Learning reaches back 14 days', () async {
+ final state = await launchApp([verge, bytebytego], feeds: {
+ verge.feedUrl: [('News from this morning', const Duration(hours: 2)),
+ ('News from yesterday', const Duration(hours: 30))],
+ bytebytego.feedUrl: [('Explainer from 2 days ago', const Duration(days: 2)),
+ ('Explainer from 13 days ago', const Duration(days: 13)),
+ ('Explainer from 15 days ago', const Duration(days: 15))],
+ });
+
+ expect(_titles(state.articlesOf(SourceKind.news)), ['News from this morning']);
+ expect(_titles(state.articlesOf(SourceKind.learning)),
+ ['Explainer from 2 days ago', 'Explainer from 13 days ago']);
+ });
+
+ test('the Briefing is generated from News articles only', () async {
+ final summarizer = _RecordingSummarizer();
+ 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))],
+ }, summarizer: summarizer);
+
+ await state.generateBriefing();
+
+ 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: {
+ verge.feedUrl: [('News from yesterday', const Duration(hours: 30)),
+ ('News from 3 days ago', const Duration(hours: 72))],
+ });
+
+ expect(_titles(state.articlesOf(SourceKind.news)), ['News from yesterday']);
+ });
+
+ test('Fun reaches back 3 days', () async {
+ final xkcd = testSource('xkcd', SourceKind.fun);
+ final state = await launchApp([xkcd], feeds: {
+ xkcd.feedUrl: [('Comic from 2 days ago', const Duration(days: 2)),
+ ('Comic from 4 days ago', const Duration(days: 4))],
+ });
+
+ expect(_titles(state.articlesOf(SourceKind.fun)), ['Comic from 2 days ago']);
+ });
+
+ test('custom Sources saved before kinds existed load as News', () async {
+ SharedPreferences.setMockInitialValues({
+ 'custom_sources': jsonEncode([
+ {'id': 'custom-1', 'name': 'My blog', 'feedUrl': 'https://blog.example.com/feed',
+ 'homepage': 'https://blog.example.com', 'language': 'de', 'description': ''},
+ ]),
+ });
+
+ final state = await launchApp([verge]);
+
+ expect(state.customSources.single.kind, SourceKind.news);
+ });
+
+ test('a custom Learning source is still a Learning source after a restart', () async {
+ final before = await launchApp([verge]);
+ await before.addCustomSource(
+ name: 'Engineering blog',
+ feedUrl: 'https://eng.example.com/feed',
+ language: SourceLanguage.en,
+ kind: SourceKind.learning,
+ );
+
+ final after = await launchApp([verge]);
+
+ expect(after.customSources.single.name, 'Engineering blog');
+ expect(after.customSources.single.kind, SourceKind.learning);
+ });
+
+ test('paid and sponsored Learning articles are hidden like News ones', () async {
+ final state = await launchApp([bytebytego], feeds: {
+ bytebytego.feedUrl: [('How DNS works', const Duration(days: 1)),
+ ('Sponsored: Try our database', const Duration(days: 1))],
+ });
+
+ expect(_titles(state.articlesOf(SourceKind.learning)), ['How DNS works']);
+ expect(state.hiddenCountOf(SourceKind.learning), 1);
+ });
+
+ test('every curated Source from before kinds is a News source', () {
+ final older = defaultSources.where((s) => legacySeenSourceIds.contains(s.id));
+
+ expect(older, isNotEmpty);
+ expect(older.where((s) => s.kind != SourceKind.news), isEmpty);
+ });
+
+ test('ByteByteGo is a Learning source that is on by default', () {
+ final bytebytego = defaultSources.singleWhere((s) => s.name == 'ByteByteGo');
+
+ expect(bytebytego.kind, SourceKind.learning);
+ expect(bytebytego.feedUrl, 'https://blog.bytebytego.com/feed');
+ expect(bytebytego.language, SourceLanguage.en);
+ expect(bytebytego.enabledByDefault, isTrue);
+ });
+
+ test('a fresh install shows posts of the last 14 days from the curated Learning sources',
+ () async {
+ final learning = defaultSources.where((s) => s.kind == SourceKind.learning);
+ final state = await launchApp(defaultSources, feeds: {
+ for (final s in learning)
+ s.feedUrl: [('${s.name}: recent', const Duration(days: 13)),
+ ('${s.name}: old', const Duration(days: 15))],
+ });
+
+ expect(_titles(state.articlesOf(SourceKind.learning)).toSet(), {
+ 'ByteByteGo: recent', 'System Design One: recent',
+ 'The Pragmatic Engineer (blog): recent', 'Martin Fowler: recent', 'Julia Evans: recent',
+ 'Dan Luu: recent', 'The Cloudflare Blog: recent', 'Netflix Technology Blog: recent',
+ 'Engineering at Meta: recent', 'GitHub Blog: Engineering: recent', 'Latent Space: recent',
+ 'Import AI: recent', 'Interconnects: recent', 'Ahead of AI: recent',
+ 'Lilian Weng: recent',
+ // InfoQ is off by default.
+ });
+ });
+}
diff --git a/test/source_selection_test.dart b/test/source_selection_test.dart
new file mode 100644
index 0000000..f2027e1
--- /dev/null
+++ b/test/source_selection_test.dart
@@ -0,0 +1,119 @@
+import 'package:daily_tech_news/data/default_sources.dart';
+import 'package:daily_tech_news/models/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 fetchAll(List sources,
+ {required Duration Function(Source source) windowOf, String corsProxy = ''}) async =>
+ const FeedResult([], {});
+}
+
+Source _source(String id, {bool onByDefault = false}) => Source(
+ id: id,
+ name: id,
+ feedUrl: 'https://example.com/$id.xml',
+ homepage: 'https://example.com',
+ language: SourceLanguage.en,
+ enabledByDefault: onByDefault,
+ );
+
+/// The curated Learning sources that are on by default (all but InfoQ).
+const _learningOnByDefault = {
+ 'bytebytego', 'systemdesignone', 'pragmaticengineer-blog', 'martinfowler', 'juliaevans',
+ 'danluu', 'cloudflare-blog', 'netflix-techblog', 'meta-engineering', 'github-engineering',
+ 'latent-space', 'import-ai', 'interconnects', 'ahead-of-ai', 'lilianweng',
+};
+
+/// Starts the app the way a launch does, against whatever is in preferences.
+Future _launch(List 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', ..._learningOnByDefault,
+ 'xkcd', 'thedailywtf',
+ });
+ });
+
+ 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', ..._learningOnByDefault,
+ 'xkcd', 'thedailywtf',
+ });
+ expect(state.enabledSources.map((s) => s.id), isNot(contains('wired')));
+ expect(state.enabledSourceIds, isNot(contains('infoq')));
+ });
+}
diff --git a/test/sources_tab_test.dart b/test/sources_tab_test.dart
new file mode 100644
index 0000000..0c0c1da
--- /dev/null
+++ b/test/sources_tab_test.dart
@@ -0,0 +1,184 @@
+import 'package:daily_tech_news/models/source.dart';
+import 'package:daily_tech_news/state/app_state.dart';
+import 'package:daily_tech_news/ui/home_shell.dart';
+import 'package:daily_tech_news/ui/learn_screen.dart';
+import 'package:daily_tech_news/ui/sources_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';
+
+Source _source(String id, SourceKind kind, SourceLanguage language) => Source(
+ id: id,
+ name: id,
+ feedUrl: 'https://example.com/$id.xml',
+ homepage: 'https://example.com',
+ language: language,
+ kind: kind,
+ enabledByDefault: true,
+ );
+
+void main() {
+ final heise = _source('heise', SourceKind.news, SourceLanguage.de);
+ final verge = _source('verge', SourceKind.news, SourceLanguage.en);
+ final bytebytego = _source('bytebytego', SourceKind.learning, SourceLanguage.en);
+ final xkcd = _source('xkcd', SourceKind.fun, SourceLanguage.en);
+
+ const german = '🇩🇪 Deutsch';
+ const english = '🇬🇧 English';
+
+ setUp(() => SharedPreferences.setMockInitialValues({}));
+
+ Future launch(WidgetTester tester, List sources) async =>
+ (await tester.runAsync(() => launchApp(sources)))!;
+
+ Future showSources(WidgetTester tester, AppState state) async {
+ tester.view.physicalSize = const Size(800, 2400);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.reset);
+ await tester.pumpWidget(
+ MaterialApp(home: SourcesScreen(state: state, onChanged: () {})));
+ }
+
+ /// Asserts the [finders] each match once on screen, from top to bottom.
+ void expectInOrder(WidgetTester tester, List finders) {
+ final ys = [for (final f in finders) tester.getTopLeft(f).dy];
+ expect(ys, [...ys]..sort());
+ expect(ys.toSet().length, ys.length);
+ }
+
+ testWidgets('the Sources tab groups News, Learning and Fun, each split by language',
+ (tester) async {
+ final state = await launch(tester, [xkcd, bytebytego, verge, heise]);
+
+ await showSources(tester, state);
+
+ expectInOrder(tester, [
+ find.text('News'),
+ find.text(german).at(0),
+ find.text('heise'),
+ find.text(english).at(0),
+ find.text('verge'),
+ find.text('Learning'),
+ find.text(english).at(1),
+ find.text('bytebytego'),
+ find.text('Fun'),
+ find.text(english).at(2),
+ find.text('xkcd'),
+ ]);
+ });
+
+ testWidgets('a kind without Sources gets no section', (tester) async {
+ final state = await launch(tester, [verge]);
+
+ await showSources(tester, state);
+
+ expect(find.text('News'), findsOneWidget);
+ expect(find.text('Learning'), findsNothing);
+ expect(find.text('Fun'), findsNothing);
+ expect(find.text(german), findsNothing);
+ });
+
+ Future addSource(WidgetTester tester, String name, {String? kind}) async {
+ await tester.tap(find.text('Add feed'));
+ await tester.pumpAndSettle();
+ final dialog = find.byType(AlertDialog);
+ await tester.enterText(
+ find.descendant(of: dialog, matching: find.widgetWithText(TextField, 'Name')), name);
+ await tester.enterText(
+ find.descendant(of: dialog, matching: find.widgetWithText(TextField, 'Feed URL')),
+ 'https://example.com/$name.xml');
+ if (kind != null) {
+ await tester.tap(find.descendant(of: dialog, matching: find.text(kind)));
+ await tester.pump();
+ }
+ await tester.tap(find.descendant(of: dialog, matching: find.text('Add')));
+ await tester.pumpAndSettle();
+ }
+
+ 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 addSource(tester, 'myfeed');
+
+ expectInOrder(tester, [find.text('News'), find.text('myfeed'), find.text('Learning')]);
+ });
+
+ 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 addSource(tester, 'myblog', kind: 'Learning');
+
+ expectInOrder(tester, [find.text('Learning'), find.text('myblog')]);
+ });
+
+ testWidgets('a Source added as Fun gets a Fun section', (tester) async {
+ final state = await launch(tester, [verge]);
+ await showSources(tester, state);
+
+ await addSource(tester, 'mycomic', kind: 'Fun');
+
+ expectInOrder(tester, [find.text('News'), find.text('Fun'), find.text('mycomic')]);
+ });
+
+ testWidgets('a custom Learning source shows up in the Learn tab, not in Today',
+ (tester) async {
+ final feeds = FakeFeeds({
+ verge.feedUrl: [('News from this morning', const Duration(hours: 2))],
+ 'https://example.com/myblog.xml': [('Explainer from my blog', const Duration(days: 2))],
+ });
+ final state = (await tester.runAsync(() => launchApp([verge], feedService: feeds)))!;
+ tester.view.physicalSize = const Size(800, 2400);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.reset);
+ await tester.pumpWidget(MaterialApp(home: HomeShell(state: state)));
+ Finder tab(String label) =>
+ find.descendant(of: find.byType(NavigationBar), matching: find.text(label));
+
+ await tester.tap(tab('Sources'));
+ await tester.pump();
+ await addSource(tester, 'myblog', kind: 'Learning');
+ await tester.tap(tab('Learn'));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Explainer from my blog'), findsOneWidget);
+
+ await tester.tap(tab('Today'));
+ await tester.pumpAndSettle();
+ expect(find.text('News from this morning'), findsOneWidget);
+ expect(find.text('Explainer from my blog'), findsNothing);
+ });
+
+ testWidgets('a custom Fun source shows up in the Fun section of the Learn tab, not in Today',
+ (tester) async {
+ final feeds = FakeFeeds({
+ verge.feedUrl: [('News from this morning', const Duration(hours: 2))],
+ 'https://example.com/mycomic.xml': [('Comic from my feed', const Duration(days: 1))],
+ });
+ final state = (await tester.runAsync(() => launchApp([verge], feedService: feeds)))!;
+ tester.view.physicalSize = const Size(800, 2400);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.reset);
+ await tester.pumpWidget(MaterialApp(home: HomeShell(state: state)));
+ Finder tab(String label) =>
+ find.descendant(of: find.byType(NavigationBar), matching: find.text(label));
+ Finder onLearn(String text) =>
+ find.descendant(of: find.byType(LearnScreen), matching: find.text(text));
+
+ await tester.tap(tab('Sources'));
+ await tester.pump();
+ await addSource(tester, 'mycomic', kind: 'Fun');
+ await tester.tap(tab('Learn'));
+ await tester.pumpAndSettle();
+
+ expectInOrder(tester, [onLearn('Learning'), onLearn('Fun'), onLearn('Comic from my feed')]);
+
+ await tester.tap(tab('Today'));
+ await tester.pumpAndSettle();
+ expect(find.text('News from this morning'), findsOneWidget);
+ expect(find.text('Comic from my feed'), findsNothing);
+ });
+}
diff --git a/test/support/fake_app.dart b/test/support/fake_app.dart
new file mode 100644
index 0000000..daad923
--- /dev/null
+++ b/test/support/fake_app.dart
@@ -0,0 +1,71 @@
+import 'package:daily_tech_news/models/article.dart';
+import 'package:daily_tech_news/models/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';
+import 'package:daily_tech_news/state/app_state.dart';
+
+/// Serves canned articles per Source instead of the network. The real
+/// [FeedService.fetchAll] still applies the time windows.
+class FakeFeeds extends FeedService {
+ FakeFeeds([this.byFeedUrl = const {}]);
+
+ /// Feed URL -> (title, age) of each article in the feed.
+ final Map> byFeedUrl;
+
+ /// How often the app refreshed all Sources.
+ int refreshes = 0;
+
+ @override
+ Future fetchAll(List sources,
+ {required Duration Function(Source source) windowOf, String corsProxy = ''}) {
+ refreshes++;
+ return super.fetchAll(sources, windowOf: windowOf, corsProxy: corsProxy);
+ }
+
+ @override
+ Future> fetchSource(Source source, {String corsProxy = ''}) async {
+ final now = DateTime.now().toUtc();
+ // A feed nobody set up still has an article, so it can be added.
+ if (!byFeedUrl.containsKey(source.feedUrl)) {
+ return [Article(title: 'Any', link: '${source.feedUrl}#any', source: source)];
+ }
+ return [
+ for (final (title, age) in byFeedUrl[source.feedUrl]!)
+ Article(
+ title: title,
+ link: '${source.feedUrl}#$title',
+ source: source,
+ published: now.subtract(age),
+ ),
+ ];
+ }
+}
+
+Source testSource(String id, SourceKind kind) => Source(
+ id: id,
+ name: id,
+ feedUrl: 'https://example.com/$id.xml',
+ homepage: 'https://example.com',
+ language: SourceLanguage.en,
+ kind: kind,
+ enabledByDefault: true,
+ );
+
+/// Starts the app the way a launch does, against whatever is in preferences.
+Future launchApp(
+ List curatedSources, {
+ Map> feeds = const {},
+ FeedService? feedService,
+ Summarizer? summarizer,
+ ArticleExtractor? extractor,
+}) async {
+ final state = AppState(await Storage.open(),
+ feedService: feedService ?? FakeFeeds(feeds),
+ curatedSources: curatedSources,
+ summarizer: summarizer,
+ extractor: extractor);
+ await state.init();
+ return state;
+}