Compare commits
18
Commits
main
...
review-fixes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b54b3515b | ||
|
|
130c5e6bb7 | ||
|
|
b773c363fe | ||
|
|
246564f1d9 | ||
|
|
49982c5467 | ||
|
|
a47ff28ed1 | ||
|
|
1a3442ea02 | ||
|
|
1048e4e384 | ||
|
|
84cb11e4dd | ||
|
|
8240a767ef | ||
|
|
4f035e4bb4 | ||
|
|
b634118ed8 | ||
|
|
f205452b25 | ||
|
|
a6b8d8b537 | ||
|
|
64c4a11105 | ||
|
|
c86609424c | ||
|
|
8c8ffca5dc | ||
|
|
ea37a23c08 |
No files matched your search
@@ -63,3 +63,6 @@ key.properties
|
||||
|
||||
# Flutter version manager
|
||||
.fvm/
|
||||
|
||||
# Claude Code agent worktrees
|
||||
.claude/worktrees/
|
||||
@@ -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
|
||||
```
|
||||
+262
-45
@@ -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 = <NewsSource>[
|
||||
const defaultSources = <Source>[
|
||||
// --- German -------------------------------------------------------------
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'heise',
|
||||
name: 'heise online',
|
||||
feedUrl: 'https://www.heise.de/rss/heise-atom.xml',
|
||||
@@ -13,7 +13,7 @@ const defaultSources = <NewsSource>[
|
||||
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 = <NewsSource>[
|
||||
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 = <NewsSource>[
|
||||
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 = <NewsSource>[
|
||||
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 = <NewsSource>[
|
||||
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 = <NewsSource>[
|
||||
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 = <NewsSource>[
|
||||
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 = <NewsSource>[
|
||||
),
|
||||
|
||||
// --- 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 = <NewsSource>[
|
||||
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 = <NewsSource>[
|
||||
language: SourceLanguage.en,
|
||||
description: 'AI, biotech, climate tech',
|
||||
),
|
||||
NewsSource(
|
||||
id: 'wired',
|
||||
name: 'WIRED',
|
||||
feedUrl: 'https://www.wired.com/feed/rss',
|
||||
homepage: 'https://www.wired.com',
|
||||
language: SourceLanguage.en,
|
||||
description: 'Tech, science and culture',
|
||||
),
|
||||
NewsSource(
|
||||
id: 'engadget',
|
||||
name: 'Engadget',
|
||||
feedUrl: 'https://www.engadget.com/rss.xml',
|
||||
homepage: 'https://www.engadget.com',
|
||||
language: SourceLanguage.en,
|
||||
description: 'Gadgets and consumer electronics',
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'theregister',
|
||||
name: 'The Register',
|
||||
feedUrl: 'https://www.theregister.com/headlines.atom',
|
||||
@@ -176,7 +192,7 @@ const defaultSources = <NewsSource>[
|
||||
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 = <NewsSource>[
|
||||
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',
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart' show ThemeMode;
|
||||
|
||||
import 'news_source.dart';
|
||||
import 'source.dart';
|
||||
|
||||
enum AiProvider {
|
||||
none('Off'),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<String, dynamic> json) => NewsSource(
|
||||
factory Source.custom(Map<String, dynamic> 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,
|
||||
);
|
||||
@@ -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 = <ReaderBlock>[];
|
||||
final seenImages = <String>{};
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<Article> parse(String body, NewsSource source) {
|
||||
static List<Article> 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
|
||||
|
||||
@@ -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<FeedResult> fetchAll(
|
||||
List<NewsSource> sources, {
|
||||
required Duration maxAge,
|
||||
List<Source> sources, {
|
||||
required Duration Function(Source source) windowOf,
|
||||
String corsProxy = '',
|
||||
}) async {
|
||||
final errors = <String, String>{};
|
||||
@@ -43,12 +44,15 @@ class FeedService {
|
||||
}
|
||||
}));
|
||||
|
||||
final cutoff = DateTime.now().toUtc().subtract(maxAge);
|
||||
final now = DateTime.now().toUtc();
|
||||
final seen = <String>{};
|
||||
final articles = <Article>[
|
||||
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<List<Article>> fetchSource(NewsSource source, {String corsProxy = ''}) async {
|
||||
Future<List<Article>> fetchSource(Source source, {String corsProxy = ''}) async {
|
||||
final url = kIsWeb && corsProxy.isNotEmpty
|
||||
? '$corsProxy${Uri.encodeComponent(source.feedUrl)}'
|
||||
: source.feedUrl;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
/// `<p><a href="{post url}">Read more</a></p>`, as the very end. Free posts
|
||||
/// end with their own text, even when they ask readers to subscribe.
|
||||
static final _substackCut = RegExp(
|
||||
r'<p>\s*<a href="([^"]+)">\s*Read more\s*</a>\s*</p>\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);
|
||||
|
||||
|
||||
@@ -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<void> saveEnabledSourceIds(Set<String> ids) =>
|
||||
_prefs.setStringList('enabled_sources', ids.toList());
|
||||
|
||||
List<NewsSource> 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<String>? loadSeenSourceIds() => _prefs.getStringList('seen_sources')?.toSet();
|
||||
|
||||
Future<void> saveSeenSourceIds(Set<String> ids) =>
|
||||
_prefs.setStringList('seen_sources', ids.toList());
|
||||
|
||||
List<Source> loadCustomSources() {
|
||||
final raw = _prefs.getString('custom_sources');
|
||||
if (raw == null) return [];
|
||||
return (jsonDecode(raw) as List)
|
||||
.map((j) => NewsSource.custom(j as Map<String, dynamic>))
|
||||
.map((j) => Source.custom(j as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> saveCustomSources(List<NewsSource> sources) => _prefs.setString(
|
||||
Future<void> saveCustomSources(List<Source> 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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
+71
-19
@@ -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<Source> 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<Source> _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<NewsSource> customSources = [];
|
||||
List<Source> customSources = [];
|
||||
Set<String> enabledSourceIds = {};
|
||||
|
||||
/// Everything the feeds returned; see [visibleArticles] for what is shown.
|
||||
/// Everything the feeds returned; see [articlesOf] for what is shown.
|
||||
List<Article> articles = [];
|
||||
Map<String, String> 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<Article> 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<Article> _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<Article> articlesOf(SourceKind kind) => _articlesWhere((k) => k == kind).toList();
|
||||
|
||||
List<NewsSource> 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<NewsSource> get enabledSources =>
|
||||
/// What the Briefing is made from: the shown articles of the kinds that
|
||||
/// feed it (News).
|
||||
List<Article> 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<Source> get allSources => [..._curated, ...customSources];
|
||||
|
||||
List<Source> get enabledSources =>
|
||||
allSources.where((s) => enabledSourceIds.contains(s.id)).toList();
|
||||
|
||||
Future<void> init() async {
|
||||
settings = await _storage.loadSettings();
|
||||
customSources = _storage.loadCustomSources();
|
||||
enabledSourceIds = _storage.loadEnabledSourceIds() ??
|
||||
defaultSources.where((s) => s.enabledByDefault).map((s) => s.id).toSet();
|
||||
await _enableNewDefaultSources();
|
||||
briefings = await _storage.loadBriefingHistory();
|
||||
notifyListeners();
|
||||
await refresh();
|
||||
@@ -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<void> 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<void> _enableNewDefaultSources() async {
|
||||
final saved = _storage.loadEnabledSourceIds();
|
||||
final seen = _storage.loadSeenSourceIds() ??
|
||||
(saved == null ? const <String>{} : legacySeenSourceIds);
|
||||
final unseen = _curated.where((s) => !seen.contains(s.id)).toList();
|
||||
final known = {for (final s in allSources) s.id};
|
||||
enabledSourceIds = {
|
||||
...?saved?.where(known.contains),
|
||||
for (final s in unseen)
|
||||
if (s.enabledByDefault) s.id,
|
||||
};
|
||||
final dropped = saved != null && saved.any((id) => !known.contains(id));
|
||||
if (unseen.isEmpty && !dropped) return;
|
||||
await _storage.saveSeenSourceIds({...seen, ...unseen.map((s) => s.id)});
|
||||
await _storage.saveEnabledSourceIds(enabledSourceIds);
|
||||
}
|
||||
|
||||
Future<void> setSourceEnabled(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<void> removeCustomSource(NewsSource source) async {
|
||||
Future<void> 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<void> 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();
|
||||
|
||||
@@ -275,12 +275,20 @@ class _ReaderView extends StatelessWidget {
|
||||
builder: (context, snapshot) {
|
||||
final loaded = snapshot.data;
|
||||
var blocks = loaded?.blocks ?? const <ReaderBlock>[];
|
||||
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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<void> 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<void> 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({
|
||||
|
||||
+12
-28
@@ -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<FeedScreen> {
|
||||
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<FeedScreen> {
|
||||
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<FeedScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _filterBar(List<NewsSource> sources) {
|
||||
Widget _filterBar(List<Source> sources) {
|
||||
sources.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
return SizedBox(
|
||||
height: 52,
|
||||
@@ -157,7 +141,7 @@ class _FeedScreenState extends State<FeedScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
static const _placeholder = NewsSource(
|
||||
static const _placeholder = Source(
|
||||
id: '', name: 'All sources', feedUrl: '', homepage: '', language: SourceLanguage.en);
|
||||
}
|
||||
|
||||
|
||||
+29
-14
@@ -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<HomeShell> {
|
||||
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<HomeShell> {
|
||||
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<HomeShell> {
|
||||
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),
|
||||
|
||||
@@ -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<Widget> _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),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
+52
-26
@@ -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<Widget> _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<void> _addFeed(BuildContext context) async {
|
||||
final added = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -93,7 +109,7 @@ class _SourceTile extends StatelessWidget {
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
final NewsSource source;
|
||||
final Source source;
|
||||
final bool enabled;
|
||||
final ValueChanged<bool> 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<SourceKind>(
|
||||
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)),
|
||||
|
||||
@@ -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 = '''
|
||||
<!DOCTYPE html>
|
||||
<html><head><title>xkcd: Momentum Demonstration</title></head>
|
||||
<body>
|
||||
<div id="topContainer">
|
||||
<div id="topLeft"><ul><li><a href="/archive">Archive</a></li><li><a href="/about">About</a></li></ul></div>
|
||||
<div id="topRight">
|
||||
<div id="masthead"><span><a href="/"><img src="/s/0b7742.png" alt="xkcd.com logo" height="83" width="185"/></a></span>
|
||||
<span id="slogan">A webcomic of romance,<br/> sarcasm, math, and language.</span></div>
|
||||
<div id="news"><div id="xkcdNews"><a href="https://store.xkcd.com/"><img src="https://imgs.xkcd.com/store_news/news_back.png" alt="The xkcd store is back, with shirts, stickers, and posters for sale." title="xkcd store news" /></a></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="middleContainer" class="box">
|
||||
<div id="ctitle">Momentum Demonstration</div>
|
||||
<ul class="comicNav"><li><a href="/1/">|<</a></li><li><a rel="prev" href="/3311/">< Prev</a></li></ul>
|
||||
<div id="comic">
|
||||
<img src="//imgs.xkcd.com/comics/momentum_demonstration.png" title=""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."" alt="Momentum Demonstration" srcset="//imgs.xkcd.com/comics/momentum_demonstration_2x.png 2x" style="image-orientation:none" />
|
||||
</div>
|
||||
<ul class="comicNav"><li><a href="/1/">|<</a></li><li><a rel="prev" href="/3311/">< Prev</a></li></ul>
|
||||
<br />
|
||||
Permanent link to this comic: <a href="https://xkcd.com/3312">https://xkcd.com/3312/</a><br />
|
||||
Image URL (for hotlinking/embedding): <a href= "https://imgs.xkcd.com/comics/momentum_demonstration.png">https://imgs.xkcd.com/comics/momentum_demonstration.png</a>
|
||||
<div id="transcript" style="display: none"></div>
|
||||
</div>
|
||||
<div id="bottom" class="box">
|
||||
<img src="//imgs.xkcd.com/s/a899e84.jpg" width="520" height="100" alt="Selected Comics" usemap="#comicmap"/>
|
||||
<map id="comicmap" name="comicmap"><area shape="rect" coords="0,0,100,100" href="/150/" alt="Grownups"/></map>
|
||||
<div id="comicLinks">Comics I enjoy:<br/><a href="http://threewordphrase.com/">Three Word Phrase</a></div>
|
||||
<div id="footnote" style="width:70%">xkcd.com is best viewed with Netscape Navigator 4.0 or below on a Pentium 3±1 emulated in Javascript on an Apple IIGS</div>
|
||||
<div id="licenseText">
|
||||
<p>
|
||||
This work is licensed under a
|
||||
<a href="https://creativecommons.org/licenses/by-nc/2.5/">Creative Commons Attribution-NonCommercial 2.5 License</a>.
|
||||
</p><p>
|
||||
This means you're free to copy and share these comics (but not to sell them). <a rel="license" href="/license.html">More details</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>''';
|
||||
|
||||
/// Trimmed from https://thedailywtf.com/articles/scrollpocalypse.
|
||||
const errordPage = '''
|
||||
<html><body>
|
||||
<div itemscope itemtype="http://schema.org/Article" class="article-container errord">
|
||||
<div itemprop="articleBody" class="article-body">
|
||||
<p>Dr. Doom...scroller titled this one "Scrollbar-ception". Said he "Screenshot of the TikTok webpage."</p>
|
||||
<blockquote><p><a href="#d6711f9e"><img itemprop="image" border="0" alt="d6711f9e" src="https://cdn.example.com/errord/first.png"/></a></p><p> </p></blockquote>
|
||||
<p>An anonymous reader clarified "I was reorganizing the document by drag-and-dropping table cells."</p>
|
||||
<blockquote><p><a href="#01270f37"><img itemprop="image" border="0" alt="01270f37" src="https://cdn.example.com/errord/second.png"/></a></p><p> </p></blockquote>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>''';
|
||||
|
||||
/// Answers every request as if the server had redirected to [finalUrl].
|
||||
class _RedirectingClient extends http.BaseClient {
|
||||
_RedirectingClient(this.finalUrl, this.body);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<List<int>> _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<String> _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<Finder> _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<void>.delayed(const Duration(milliseconds: 20)));
|
||||
await tester.pump();
|
||||
if (picture.evaluate().isNotEmpty && tester.widget<RawImage>(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 = '''
|
||||
<html><body>
|
||||
<div id="ctitle">Strip</div>
|
||||
<div id="comic"><img src="$comicUrl" title="$_titleText" alt="Strip"></div>
|
||||
<div id="licenseText"><p>This work is licensed under a Creative Commons Attribution-NonCommercial 2.5 License.</p></div>
|
||||
</body></html>''';
|
||||
// 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<RawImage>(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<RawImage>(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);
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Ahead of AI]]></title><link>https://magazine.sebastianraschka.com</link><generator>Substack</generator>
|
||||
<!-- Real items from https://magazine.sebastianraschka.com/feed (October 2026), content:encoded trimmed to its first paragraph and its end. -->
|
||||
<!-- Paid post (audience "only_paid" on its page): Substack cuts the content and ends it with a "Read more" link to the post. -->
|
||||
<item><title><![CDATA[Building an AI Text Detector From Scratch]]></title><description><![CDATA[An End-to-End Project With Dataset Construction, Model Training, Local Deployment, and RLVR]]></description><link>https://magazine.sebastianraschka.com/p/ai-detector-from-scratch</link><guid isPermaLink="false">https://magazine.sebastianraschka.com/p/ai-detector-from-scratch</guid><dc:creator><![CDATA[Sebastian Raschka, PhD]]></dc:creator><pubDate>Sat, 15 Aug 2026 11:54:24 GMT</pubDate><content:encoded><![CDATA[<p>Substack recently launched its AI detector feature in the UI, which is super interesting.</p><p>For this, we are going to fine-tune a DistilBERT classifier (similar to what I described in one of my early Substack articles, <a href="https://magazine.sebastianraschka.com/p/finetuning-large-language-models">Finetuning Large Language Models</a>), but more details on that later when we get to that stage.</p><p></p>
|
||||
<p>
|
||||
<a href="https://magazine.sebastianraschka.com/p/ai-detector-from-scratch">
|
||||
Read more
|
||||
</a>
|
||||
</p>
|
||||
]]></content:encoded></item>
|
||||
<!-- Free post (audience "everyone") whose footer asks readers to become paid subscribers. -->
|
||||
<item><title><![CDATA[Controlling Reasoning Effort in LLMs]]></title><description><![CDATA[How LLMs Learn Low-, Medium-, and High-Effort Reasoning Modes]]></description><link>https://magazine.sebastianraschka.com/p/controlling-reasoning-effort-in-llms</link><guid isPermaLink="false">https://magazine.sebastianraschka.com/p/controlling-reasoning-effort-in-llms</guid><dc:creator><![CDATA[Sebastian Raschka, PhD]]></dc:creator><pubDate>Sat, 18 Jul 2026 11:16:09 GMT</pubDate><content:encoded><![CDATA[<p><span>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.</span></p><p>If you liked my previous <a href="https://amzn.to/4fqvn0D">Build a Large Language Model (From Scratch)</a> book, this is essentially a sequel implementing inference-time scaling techniques and reinforcement learning algorithms from scratch.</p><p>And if you want to support future long-form articles like this one, consider <a href="https://magazine.sebastianraschka.com/subscribe">becoming a paid subscriber</a>. It helps me keep writing these independent deep dives and sharing the accompanying code, figures, and experiments.</p><p></p>]]></content:encoded></item>
|
||||
<!-- Free post (audience "everyone") that ends with Substack's subscribe widget. -->
|
||||
<item><title><![CDATA[GPT-6 Astra, Looped Transformers, and Hidden Reasoning]]></title><description><![CDATA[A Look at Recurrent Depth, Hidden Chains of Thought, and Recent Research on Looping Transformer Blocks]]></description><link>https://magazine.sebastianraschka.com/p/gpt-6-astra-looped-transformers-and</link><guid isPermaLink="false">https://magazine.sebastianraschka.com/p/gpt-6-astra-looped-transformers-and</guid><dc:creator><![CDATA[Sebastian Raschka, PhD]]></dc:creator><pubDate>Wed, 09 Sep 2026 11:14:25 GMT</pubDate><content:encoded><![CDATA[<p>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).</p><div class="subscription-widget-wrap-editor" data-attrs="{"url":"https://magazine.sebastianraschka.com/subscribe?","text":"Subscribe","language":"en"}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption"></p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email…" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item>
|
||||
</channel></rss>
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Interconnects AI]]></title><link>https://www.interconnects.ai</link><generator>Substack</generator>
|
||||
<!-- Real items from https://www.interconnects.ai/feed (October 2026), content:encoded trimmed to its first paragraph and its end. -->
|
||||
<!-- Paid post (audience "only_paid" on its page). -->
|
||||
<item><title><![CDATA[Lessons from the hacks]]></title><description><![CDATA[Musings on model alignment, what determines safety, and where we go from here.]]></description><link>https://www.interconnects.ai/p/lessons-from-the-hacks</link><guid isPermaLink="false">https://www.interconnects.ai/p/lessons-from-the-hacks</guid><dc:creator><![CDATA[Nathan Lambert]]></dc:creator><pubDate>Sun, 09 Aug 2026 14:57:11 GMT</pubDate><content:encoded><![CDATA[<p><span>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.</span></p><div><hr></div><h4>Book sale</h4><p><span>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:</span></p>
|
||||
<p>
|
||||
<a href="https://www.interconnects.ai/p/lessons-from-the-hacks">
|
||||
Read more
|
||||
</a>
|
||||
</p>
|
||||
]]></content:encoded></item>
|
||||
<!-- Free post (audience "everyone") that mentions paid subscribers and ends with an embedded post's "Read more". -->
|
||||
<item><title><![CDATA[When will average people feel AI’s impact?]]></title><description><![CDATA[We’re <5 years into a compounding revolution which could take a century, and how the AI industry should manage this.]]></description><link>https://www.interconnects.ai/p/when-will-average-people-feel-ais</link><guid isPermaLink="false">https://www.interconnects.ai/p/when-will-average-people-feel-ais</guid><dc:creator><![CDATA[Nathan Lambert]]></dc:creator><pubDate>Wed, 09 Sep 2026 11:01:23 GMT</pubDate><content:encoded><![CDATA[<h5>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 <a href="https://www.interconnects.ai/perks">perks page</a>.</h5><p>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.</p><div><a class="embedded-post" native="true" href="https://www.fabricatedknowledge.com/p/mythos-and-engels-pause?utm_source=substack&utm_campaign=post_embed&utm_medium=web&embedding_publication_id=48206"><div class="embedded-post-header"><span class="embedded-post-publication-name">Fabricated Knowledge</span></div><div class="embedded-post-title-wrapper"><div class="embedded-post-title">Engels' Pause and the Permanent Underclass </div></div><div class="embedded-post-cta-wrapper"><span class="embedded-post-cta">Read more</span></div><div class="embedded-post-meta">6 months ago · 214 likes · 5 comments · Doug O'Laughlin</div></a></div>]]></content:encoded></item>
|
||||
</channel></rss>
|
||||
@@ -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<Article>? briefingInput;
|
||||
|
||||
@override
|
||||
String get name => 'test';
|
||||
|
||||
@override
|
||||
Future<String> summarizeArticle(Article article, String text, SourceLanguage language) async =>
|
||||
'';
|
||||
|
||||
@override
|
||||
Future<DailyBriefing> createBriefing(List<Article> 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<AppState> 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']);
|
||||
});
|
||||
}
|
||||
@@ -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<AppState> 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);
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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<String, String> fixtureByFeedUrl;
|
||||
|
||||
@override
|
||||
Future<List<Article>> 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<String> _titles(List<Article> 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);
|
||||
});
|
||||
}
|
||||
@@ -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: '<p>The first paragraph.</p><p><a href="$readMore">Read more</a></p>',
|
||||
);
|
||||
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 = <String>[];
|
||||
|
||||
@@ -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<Article>? briefingInput;
|
||||
|
||||
@override
|
||||
String get name => 'test';
|
||||
|
||||
@override
|
||||
Future<String> summarizeArticle(Article article, String text, SourceLanguage language) async =>
|
||||
'';
|
||||
|
||||
@override
|
||||
Future<DailyBriefing> createBriefing(List<Article> articles, SourceLanguage language) async {
|
||||
briefingInput = articles;
|
||||
return DailyBriefing(createdAt: DateTime.now(), intro: '', topics: const [], generatedBy: name);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _titles(List<Article> 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.
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<FeedResult> fetchAll(List<Source> 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<AppState> _launch(List<Source> 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')));
|
||||
});
|
||||
}
|
||||
@@ -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<AppState> launch(WidgetTester tester, List<Source> sources) async =>
|
||||
(await tester.runAsync(() => launchApp(sources)))!;
|
||||
|
||||
Future<void> 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<Finder> 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<void> 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);
|
||||
});
|
||||
}
|
||||
@@ -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<String, List<(String, Duration)>> byFeedUrl;
|
||||
|
||||
/// How often the app refreshed all Sources.
|
||||
int refreshes = 0;
|
||||
|
||||
@override
|
||||
Future<FeedResult> fetchAll(List<Source> sources,
|
||||
{required Duration Function(Source source) windowOf, String corsProxy = ''}) {
|
||||
refreshes++;
|
||||
return super.fetchAll(sources, windowOf: windowOf, corsProxy: corsProxy);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Article>> 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<AppState> launchApp(
|
||||
List<Source> curatedSources, {
|
||||
Map<String, List<(String, Duration)>> 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;
|
||||
}
|
||||
Reference in new issue
Block a user