Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b54b3515b | ||
|
|
130c5e6bb7 | ||
|
|
b773c363fe | ||
|
|
246564f1d9 | ||
|
|
49982c5467 | ||
|
|
a47ff28ed1 |
No files matched your search
@@ -6,29 +6,32 @@ or write a daily briefing. It's meant to replace the n8n → Discord workflow.
|
||||
|
||||
## Features (prototype)
|
||||
|
||||
- **Today**: all articles from the enabled sources in the last 12/24/48 h, newest
|
||||
first. Filter by language or source, pull to refresh.
|
||||
- **Learn**: articles from the enabled Learning sources (engineering blogs,
|
||||
system design explainers, AI research write-ups) from the last 14 days, newest
|
||||
first. Learning sources aren't part of the Briefing.
|
||||
- **Today**: all articles from the enabled News sources in the last 12/24/48 h,
|
||||
newest first. Filter by language or source, pull to refresh.
|
||||
- **Learn**: two sections, newest first. **Learning** has posts from the enabled
|
||||
Learning sources (engineering blogs, system design explainers, AI research
|
||||
write-ups) from the last 14 days; **Fun** has comics, memes and IT horror
|
||||
stories from the enabled Fun sources from the last 3 days. Neither is part of
|
||||
the Briefing.
|
||||
- **Reading in the app**: tapping an article opens a clean **reader view** (text,
|
||||
headings, lists, quotes, images) without ads or cookie banners. The globe icon
|
||||
switches to the **original page** in an in-app WebView, for paywalls or sites
|
||||
that block reader view. **Summarize with AI** uses the same downloaded text, so
|
||||
the summary is based on the full article, not just the RSS teaser. Links in the
|
||||
briefing open the same way.
|
||||
- **Briefing**: AI picks the 6–10 most important stories of the day, merges
|
||||
coverage from different outlets, and links every topic to its sources. Every
|
||||
briefing is saved on the device (`briefings.json` in the app's private
|
||||
storage); the history button lists them by month, tap to read, swipe to delete
|
||||
(with undo).
|
||||
- **Briefing**: AI picks the 6–10 most important stories of the day from the
|
||||
News sources, merges coverage from different outlets, and links every topic to
|
||||
its articles. Every briefing is saved on the device (`briefings.json` in the
|
||||
app's private storage); the history button lists them by month, tap to read,
|
||||
swipe to delete (with undo).
|
||||
- **Paid articles and ads hidden**: heise+ and Golem `(g+)` are recognized by
|
||||
their title; SPIEGEL+ and t3n Pro by a marker in the article page, which the app
|
||||
checks once per new article in the background (it reads only until the marker,
|
||||
and remembers results for 14 days). Paid-subscriber posts from Substack newsletters
|
||||
and remembers results for 14 days). Paid-subscriber posts from Sources on Substack
|
||||
(Interconnects, Ahead of AI, …) are recognized in the feed itself, where Substack
|
||||
cuts them off with a "Read more" link. Sponsored posts (`Anzeige:`) are hidden too.
|
||||
The feed header shows how many were hidden; both filters can be turned off.
|
||||
The header of Today and of each Learn section shows how many were hidden; both
|
||||
filters can be turned off.
|
||||
- **Ad and cookie-banner blocking** in the original-page view: requests to ~80
|
||||
ad/tracker hosts and the common consent managers (Sourcepoint, OneTrust,
|
||||
consentmanager, Usercentrics, Didomi, Cookiebot, …) are blocked, and a
|
||||
@@ -38,16 +41,17 @@ or write a daily briefing. It's meant to replace the n8n → Discord workflow.
|
||||
same request as Golem's own "Zustimmen" button (`/abo/setconsentcookie.php`,
|
||||
valid for a day) and reuses the cookie for reader view and the in-app page.
|
||||
With it off, the app shows the teaser and an "Open website anyway" option.
|
||||
- **Sources**: 23 curated feeds (13 German, 10 English), toggle each one, or add any
|
||||
RSS/Atom feed as a News, Learning or Fun source (it's validated before being
|
||||
added). The tab groups Sources by kind, then by language.
|
||||
- **Sources**: 42 curated Sources (13 German, 29 English): 23 News, 16 Learning
|
||||
and 3 Fun sources. Toggle each one, or add any RSS/Atom feed as a News,
|
||||
Learning or Fun source (it's validated before being added). The tab groups
|
||||
Sources by kind, then by language.
|
||||
- **Dark mode**: System / Light / Dark in Settings (applies instantly). The
|
||||
original-page view follows it too: when the phone is dark, sites use their own
|
||||
dark design or Android darkens them; when only the app is dark, the app inverts
|
||||
the page colours and keeps photos and videos as they are.
|
||||
- **Settings**: AI provider (off / Claude API / n8n webhook), model, summary
|
||||
language (German/English, whatever the source language), time window, paid/ad
|
||||
filters, ad and cookie-banner blocking.
|
||||
language (German/English, whatever the source language), time window for News,
|
||||
paid/ad filters, ad and cookie-banner blocking.
|
||||
|
||||
## Running
|
||||
|
||||
@@ -236,8 +240,8 @@ Reddit blocks reader view, so a meme shows the picture from the feed.
|
||||
```
|
||||
lib/
|
||||
main.dart App entry, theme
|
||||
data/default_sources.dart Curated feed list
|
||||
models/ Article, 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
|
||||
@@ -246,9 +250,17 @@ lib/
|
||||
claude_summarizer.dart Claude Messages API over HTTP
|
||||
n8n_summarizer.dart n8n webhook bridge
|
||||
storage.dart Settings, sources, briefing persistence
|
||||
paywall_service.dart Paid/sponsored detection (titles + page marker)
|
||||
paywall_service.dart Paid/sponsored detection (titles, Substack cut-off, page marker)
|
||||
content_blocking.dart Ad/tracker/consent blocklists for the WebView
|
||||
consent_service.dart Accepts consent walls (Golem) and keeps the cookies
|
||||
state/app_state.dart ChangeNotifier holding app state
|
||||
ui/ Screens (Today, Article, Briefing, Sources, Settings)
|
||||
ui/
|
||||
home_shell.dart Tabs: Today, Briefing, Learn, Sources, Settings
|
||||
feed_screen.dart Today: News articles with language/source filters
|
||||
briefing_screen.dart Briefing (briefing_history_screen.dart: saved ones)
|
||||
learn_screen.dart Learn: Learning and Fun sections
|
||||
sources_screen.dart Sources by kind and language, adding a Source
|
||||
settings_screen.dart Settings
|
||||
article_screen.dart Reader view and original page
|
||||
common.dart Shared helpers and widgets
|
||||
```
|
||||
@@ -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,7 +22,7 @@ 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/',
|
||||
@@ -31,7 +31,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'KI-News: Modelle, Forschung, Branche',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 't3n',
|
||||
name: 't3n',
|
||||
feedUrl: 'https://t3n.de/rss.xml',
|
||||
@@ -40,7 +40,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Digitales Business, Startups, KI',
|
||||
checkPaywallOnPage: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'computerbase',
|
||||
name: 'ComputerBase',
|
||||
feedUrl: 'https://www.computerbase.de/rss/news.xml',
|
||||
@@ -48,7 +48,7 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.de,
|
||||
description: 'Hardware, Software, Gaming',
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'spiegel-netzwelt',
|
||||
name: 'SPIEGEL Netzwelt',
|
||||
feedUrl: 'https://www.spiegel.de/netzwelt/index.rss',
|
||||
@@ -57,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',
|
||||
@@ -65,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/',
|
||||
@@ -73,7 +73,7 @@ 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',
|
||||
@@ -81,7 +81,7 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.de,
|
||||
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',
|
||||
@@ -89,7 +89,7 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.de,
|
||||
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/',
|
||||
@@ -97,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',
|
||||
@@ -105,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/',
|
||||
@@ -115,7 +115,7 @@ const defaultSources = <NewsSource>[
|
||||
),
|
||||
|
||||
// --- English ------------------------------------------------------------
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'verge',
|
||||
name: 'The Verge',
|
||||
feedUrl: 'https://www.theverge.com/rss/index.xml',
|
||||
@@ -123,7 +123,7 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.en,
|
||||
description: 'Consumer tech, culture, policy',
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'arstechnica',
|
||||
name: 'Ars Technica',
|
||||
feedUrl: 'https://feeds.arstechnica.com/arstechnica/index',
|
||||
@@ -132,7 +132,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'In-depth tech and science',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'techcrunch',
|
||||
name: 'TechCrunch',
|
||||
feedUrl: 'https://techcrunch.com/feed/',
|
||||
@@ -140,7 +140,7 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.en,
|
||||
description: 'Startups, funding, big tech',
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'hackernews',
|
||||
name: 'Hacker News',
|
||||
feedUrl: 'https://hnrss.org/frontpage?points=150',
|
||||
@@ -149,7 +149,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Front page stories with 150+ points',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'techmeme',
|
||||
name: 'Techmeme',
|
||||
feedUrl: 'https://www.techmeme.com/feed.xml',
|
||||
@@ -158,7 +158,7 @@ const defaultSources = <NewsSource>[
|
||||
description: "The day's top tech stories, picked by editors",
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'lobsters',
|
||||
name: 'Lobsters',
|
||||
feedUrl: 'https://lobste.rs/rss',
|
||||
@@ -167,7 +167,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Computing links, voted by a programmer community',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'simonwillison',
|
||||
name: 'Simon Willison',
|
||||
feedUrl: 'https://simonwillison.net/atom/entries/',
|
||||
@@ -176,7 +176,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'LLMs and AI tools, tried out hands-on',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'mit-tr',
|
||||
name: 'MIT Technology Review',
|
||||
feedUrl: 'https://www.technologyreview.com/feed/',
|
||||
@@ -184,7 +184,7 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.en,
|
||||
description: 'AI, biotech, climate tech',
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'theregister',
|
||||
name: 'The Register',
|
||||
feedUrl: 'https://www.theregister.com/headlines.atom',
|
||||
@@ -192,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/',
|
||||
@@ -202,7 +202,7 @@ const defaultSources = <NewsSource>[
|
||||
),
|
||||
|
||||
// --- Learning -----------------------------------------------------------
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'bytebytego',
|
||||
name: 'ByteByteGo',
|
||||
feedUrl: 'https://blog.bytebytego.com/feed',
|
||||
@@ -212,7 +212,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'System design explained with diagrams',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'systemdesignone',
|
||||
name: 'System Design One',
|
||||
feedUrl: 'https://newsletter.systemdesign.one/feed',
|
||||
@@ -222,7 +222,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'System design case studies of real systems',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'pragmaticengineer-blog',
|
||||
name: 'The Pragmatic Engineer (blog)',
|
||||
feedUrl: 'https://blog.pragmaticengineer.com/rss/',
|
||||
@@ -232,7 +232,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'How big tech and startups build software (free posts)',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'martinfowler',
|
||||
name: 'Martin Fowler',
|
||||
feedUrl: 'https://martinfowler.com/feed.atom',
|
||||
@@ -242,7 +242,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Software architecture, refactoring, delivery practices',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'juliaevans',
|
||||
name: 'Julia Evans',
|
||||
feedUrl: 'https://jvns.ca/atom.xml',
|
||||
@@ -252,7 +252,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Systems, networking and Unix tools, explained plainly',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'danluu',
|
||||
name: 'Dan Luu',
|
||||
feedUrl: 'https://danluu.com/atom.xml',
|
||||
@@ -262,7 +262,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Long, data-driven essays on computers and engineering',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'cloudflare-blog',
|
||||
name: 'The Cloudflare Blog',
|
||||
feedUrl: 'https://blog.cloudflare.com/rss/',
|
||||
@@ -272,7 +272,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Networking, security and incident write-ups in depth',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'netflix-techblog',
|
||||
name: 'Netflix Technology Blog',
|
||||
feedUrl: 'https://netflixtechblog.com/feed',
|
||||
@@ -282,7 +282,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Distributed systems, data and streaming at scale',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'meta-engineering',
|
||||
name: 'Engineering at Meta',
|
||||
feedUrl: 'https://engineering.fb.com/feed/',
|
||||
@@ -292,7 +292,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Infrastructure, AI and production engineering at Meta',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'github-engineering',
|
||||
name: 'GitHub Blog: Engineering',
|
||||
feedUrl: 'https://github.blog/engineering/feed/',
|
||||
@@ -302,7 +302,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'How GitHub builds and runs GitHub',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'latent-space',
|
||||
name: 'Latent Space',
|
||||
feedUrl: 'https://www.latent.space/feed',
|
||||
@@ -312,7 +312,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'AI engineering: interviews and deep dives',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'import-ai',
|
||||
name: 'Import AI',
|
||||
feedUrl: 'https://importai.substack.com/feed',
|
||||
@@ -322,7 +322,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Weekly AI research digest by Jack Clark',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'interconnects',
|
||||
name: 'Interconnects',
|
||||
feedUrl: 'https://www.interconnects.ai/feed',
|
||||
@@ -332,7 +332,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Language model training and research, analysed',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'ahead-of-ai',
|
||||
name: 'Ahead of AI',
|
||||
feedUrl: 'https://magazine.sebastianraschka.com/feed',
|
||||
@@ -342,7 +342,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'LLM research explained, by Sebastian Raschka',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'lilianweng',
|
||||
name: 'Lilian Weng',
|
||||
feedUrl: 'https://lilianweng.github.io/index.xml',
|
||||
@@ -352,7 +352,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Thorough surveys of machine learning topics',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'infoq',
|
||||
name: 'InfoQ',
|
||||
feedUrl: 'https://feed.infoq.com/',
|
||||
@@ -363,7 +363,7 @@ const defaultSources = <NewsSource>[
|
||||
),
|
||||
|
||||
// --- Fun ----------------------------------------------------------------
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'xkcd',
|
||||
name: 'xkcd',
|
||||
feedUrl: 'https://xkcd.com/atom.xml',
|
||||
@@ -373,7 +373,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Webcomic of romance, sarcasm, math and language',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'thedailywtf',
|
||||
name: 'The Daily WTF',
|
||||
feedUrl: 'https://thedailywtf.com/rss',
|
||||
@@ -383,7 +383,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'IT horror stories, bad code and error messages',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'programmerhumor',
|
||||
name: 'r/ProgrammerHumor',
|
||||
feedUrl: 'https://www.reddit.com/r/ProgrammerHumor/top/.rss?t=day',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -11,18 +11,33 @@ enum SourceLanguage {
|
||||
/// What a Source's articles are for; see GLOSSARY.md. Each kind has its own
|
||||
/// section in the app and its own time window.
|
||||
enum SourceKind {
|
||||
news('News'),
|
||||
learning('Learning'),
|
||||
fun('Fun');
|
||||
news('News', feedsBriefing: true),
|
||||
learning('Learning', fixedWindow: Duration(days: 14)),
|
||||
fun('Fun', fixedWindow: Duration(days: 3), pictureLed: true);
|
||||
|
||||
const SourceKind(this.label);
|
||||
const SourceKind(
|
||||
this.label, {
|
||||
this.feedsBriefing = false,
|
||||
this.fixedWindow,
|
||||
this.pictureLed = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
|
||||
/// Only News sources feed the Briefing.
|
||||
final bool feedsBriefing;
|
||||
|
||||
/// How far back articles reach; null where the user sets it (News).
|
||||
final Duration? fixedWindow;
|
||||
|
||||
/// Comics and memes are the picture: the reader shows it whole in the body
|
||||
/// rather than as a cropped banner.
|
||||
final bool pictureLed;
|
||||
}
|
||||
|
||||
/// A Source of any [kind]; the class name predates the kinds.
|
||||
class NewsSource {
|
||||
const NewsSource({
|
||||
/// A Source of any [kind]; see GLOSSARY.md.
|
||||
class Source {
|
||||
const Source({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.feedUrl,
|
||||
@@ -59,7 +74,7 @@ class NewsSource {
|
||||
'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,
|
||||
@@ -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,15 +23,15 @@ class FeedService {
|
||||
|
||||
static const _timeout = Duration(seconds: 20);
|
||||
|
||||
/// Fetches all [sources] in parallel and drops articles older than the age
|
||||
/// [maxAgeOf] gives for their Source. Failing feeds are reported in
|
||||
/// Fetches all [sources] in parallel and drops articles older than the time
|
||||
/// window [windowOf] gives for their Source. Failing feeds are reported in
|
||||
/// [FeedResult.errors] instead of failing the whole refresh.
|
||||
///
|
||||
/// [corsProxy] is only used on web, where browsers block cross-origin feed
|
||||
/// requests. It is prepended to the feed URL, e.g. `https://proxy/?url=`.
|
||||
Future<FeedResult> fetchAll(
|
||||
List<NewsSource> sources, {
|
||||
required Duration Function(NewsSource source) maxAgeOf,
|
||||
List<Source> sources, {
|
||||
required Duration Function(Source source) windowOf,
|
||||
String corsProxy = '',
|
||||
}) async {
|
||||
final errors = <String, String>{};
|
||||
@@ -50,7 +50,7 @@ class FeedService {
|
||||
for (final (i, list) in results.indexed)
|
||||
for (final a in list)
|
||||
if ((a.published == null ||
|
||||
a.published!.isAfter(now.subtract(maxAgeOf(sources[i])))) &&
|
||||
a.published!.isAfter(now.subtract(windowOf(sources[i])))) &&
|
||||
seen.add(a.id))
|
||||
a,
|
||||
];
|
||||
@@ -64,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,7 +9,7 @@ import 'storage.dart';
|
||||
/// Recognizes paid and sponsored articles so the feed can hide them.
|
||||
///
|
||||
/// Most paid articles are marked in the title (heise+, Golem's "(g+)").
|
||||
/// Substack newsletters cut paid posts short in the feed and end them with a
|
||||
/// Substack cuts paid posts short in the feed and ends them with a
|
||||
/// "Read more" link to the post itself, so those are recognized from the feed.
|
||||
/// SPIEGEL and t3n only mark them in the page's schema.org data
|
||||
/// (`"isAccessibleForFree": false`), so for those sources each new article is
|
||||
@@ -54,8 +54,24 @@ class PaywallService {
|
||||
bool isPaid(Article a) =>
|
||||
paidTitle.hasMatch(a.title) || _isCutSubstackPost(a) || (_checks[a.link]?.paid ?? false);
|
||||
|
||||
static bool _isCutSubstackPost(Article a) =>
|
||||
_substackCut.firstMatch(a.contentHtml)?.group(1) == a.link;
|
||||
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
|
||||
@@ -81,15 +81,15 @@ class Storage {
|
||||
Future<void> saveSeenSourceIds(Set<String> ids) =>
|
||||
_prefs.setStringList('seen_sources', ids.toList());
|
||||
|
||||
List<NewsSource> loadCustomSources() {
|
||||
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);
|
||||
|
||||
+27
-22
@@ -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';
|
||||
@@ -33,7 +33,7 @@ class AppState extends ChangeNotifier {
|
||||
this._storage, {
|
||||
FeedService? feedService,
|
||||
ArticleExtractor? extractor,
|
||||
List<NewsSource> curatedSources = defaultSources,
|
||||
List<Source> curatedSources = defaultSources,
|
||||
Summarizer? summarizer,
|
||||
}) : _feeds = feedService ?? FeedService(),
|
||||
_curated = curatedSources,
|
||||
@@ -47,7 +47,7 @@ class AppState extends ChangeNotifier {
|
||||
final FeedService _feeds;
|
||||
|
||||
/// The curated catalog; [defaultSources] outside of tests.
|
||||
final List<NewsSource> _curated;
|
||||
final List<Source> _curated;
|
||||
|
||||
/// Used instead of the AI set up in [settings]; for tests.
|
||||
final Summarizer? _summarizerOverride;
|
||||
@@ -58,7 +58,7 @@ 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 [articlesOf] for what is shown.
|
||||
@@ -82,26 +82,31 @@ class AppState extends ChangeNotifier {
|
||||
(settings.hidePaid && paywall.isPaid(a)) ||
|
||||
(settings.hideSponsored && paywall.isSponsored(a));
|
||||
|
||||
/// Articles from Sources whose kind passes [test] that the paid/sponsored
|
||||
/// filters show, or with [hidden], those they hide.
|
||||
Iterable<Article> _articlesWhere(bool Function(SourceKind kind) test,
|
||||
{bool hidden = false}) =>
|
||||
articles.where((a) => test(a.source.kind) && _isHidden(a) == hidden);
|
||||
|
||||
/// Articles from Sources of [kind] after the paid/sponsored filters. Today
|
||||
/// and the Briefing use News, the Learn tab the other kinds.
|
||||
List<Article> articlesOf(SourceKind kind) =>
|
||||
articles.where((a) => a.source.kind == kind && !_isHidden(a)).toList();
|
||||
/// uses News, the Learn tab the other kinds.
|
||||
List<Article> articlesOf(SourceKind kind) => _articlesWhere((k) => k == kind).toList();
|
||||
|
||||
/// Articles from Sources of [kind] that the paid/sponsored filters hide.
|
||||
int hiddenCountOf(SourceKind kind) =>
|
||||
articles.where((a) => a.source.kind == kind && _isHidden(a)).length;
|
||||
int hiddenCountOf(SourceKind kind) => _articlesWhere((k) => k == kind, hidden: true).length;
|
||||
|
||||
/// What the Briefing is made from: the shown articles of the kinds that
|
||||
/// feed it (News).
|
||||
List<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) => switch (kind) {
|
||||
SourceKind.news => Duration(hours: settings.maxAgeHours),
|
||||
SourceKind.learning => const Duration(days: 14),
|
||||
SourceKind.fun => const Duration(days: 3),
|
||||
};
|
||||
Duration windowOf(SourceKind kind) =>
|
||||
kind.fixedWindow ?? Duration(hours: settings.maxAgeHours);
|
||||
|
||||
List<NewsSource> get allSources => [..._curated, ...customSources];
|
||||
List<Source> get allSources => [..._curated, ...customSources];
|
||||
|
||||
List<NewsSource> get enabledSources =>
|
||||
List<Source> get enabledSources =>
|
||||
allSources.where((s) => enabledSourceIds.contains(s.id)).toList();
|
||||
|
||||
Future<void> init() async {
|
||||
@@ -119,7 +124,7 @@ class AppState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
final result = await _feeds.fetchAll(
|
||||
enabledSources,
|
||||
maxAgeOf: (source) => windowOf(source.kind),
|
||||
windowOf: (source) => windowOf(source.kind),
|
||||
corsProxy: settings.corsProxy,
|
||||
);
|
||||
articles = result.articles;
|
||||
@@ -156,7 +161,7 @@ class AppState extends ChangeNotifier {
|
||||
await _storage.saveEnabledSourceIds(enabledSourceIds);
|
||||
}
|
||||
|
||||
Future<void> setSourceEnabled(NewsSource source, bool enabled) async {
|
||||
Future<void> setSourceEnabled(Source source, bool enabled) async {
|
||||
enabled ? enabledSourceIds.add(source.id) : enabledSourceIds.remove(source.id);
|
||||
notifyListeners();
|
||||
await _storage.saveEnabledSourceIds(enabledSourceIds);
|
||||
@@ -171,7 +176,7 @@ class AppState extends ChangeNotifier {
|
||||
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,
|
||||
@@ -194,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);
|
||||
@@ -235,7 +240,7 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
final source = allSources.firstWhere(
|
||||
(s) => s.name == sourceName,
|
||||
orElse: () => NewsSource(
|
||||
orElse: () => Source(
|
||||
id: sourceName,
|
||||
name: sourceName,
|
||||
feedUrl: '',
|
||||
@@ -292,7 +297,7 @@ class AppState extends ChangeNotifier {
|
||||
Future<void> generateBriefing() async {
|
||||
final summarizer = _summarizer;
|
||||
if (summarizer == null || briefingLoading) return;
|
||||
final articles = articlesOf(SourceKind.news);
|
||||
final articles = briefingArticles;
|
||||
if (articles.isEmpty) {
|
||||
briefingError = 'No articles loaded yet. Pull to refresh the feed first.';
|
||||
notifyListeners();
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
import '../models/article.dart';
|
||||
import '../models/news_source.dart';
|
||||
import '../services/article_extractor.dart';
|
||||
import '../services/consent_service.dart';
|
||||
import '../services/content_blocking.dart';
|
||||
@@ -276,9 +275,7 @@ class _ReaderView extends StatelessWidget {
|
||||
builder: (context, snapshot) {
|
||||
final loaded = snapshot.data;
|
||||
var blocks = loaded?.blocks ?? const <ReaderBlock>[];
|
||||
// Comics and memes are the picture: show it whole in the body
|
||||
// rather than as a cropped banner.
|
||||
final pictureLed = article.source.kind == SourceKind.fun;
|
||||
final pictureLed = article.source.kind.pictureLed;
|
||||
final banner = pictureLed ? null : article.imageUrl;
|
||||
// The page's own hero image duplicates the feed image shown on top.
|
||||
if (banner != null) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/briefing.dart';
|
||||
import '../models/news_source.dart';
|
||||
import '../state/app_state.dart';
|
||||
import 'article_screen.dart';
|
||||
import 'briefing_history_screen.dart';
|
||||
@@ -60,7 +59,7 @@ class BriefingScreen extends StatelessWidget {
|
||||
if (state.briefingLoading) {
|
||||
return EmptyState(
|
||||
icon: Icons.hourglass_top,
|
||||
title: 'Reading ${state.articlesOf(SourceKind.news).length} headlines…',
|
||||
title: 'Reading ${state.briefingArticles.length} headlines…',
|
||||
message: 'This usually takes 10-60 seconds.',
|
||||
action: const SizedBox(width: 160, child: LinearProgressIndicator()),
|
||||
);
|
||||
@@ -70,7 +69,7 @@ class BriefingScreen extends StatelessWidget {
|
||||
icon: const Icon(Icons.auto_awesome),
|
||||
label: Text(state.loading
|
||||
? 'Waiting for feeds…'
|
||||
: 'Generate from ${state.articlesOf(SourceKind.news).length} articles'),
|
||||
: 'Generate from ${state.briefingArticles.length} articles'),
|
||||
);
|
||||
if (briefing == null) {
|
||||
return EmptyState(
|
||||
|
||||
@@ -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({
|
||||
|
||||
+11
-27
@@ -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';
|
||||
@@ -31,7 +31,6 @@ class _FeedScreenState extends State<FeedScreen> {
|
||||
.where((a) => _sourceId == null || a.source.id == _sourceId)
|
||||
.toList();
|
||||
final sourcesInFeed = {for (final a in all) a.source.id: a.source};
|
||||
final hidden = state.hiddenCountOf(SourceKind.news);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -40,38 +39,23 @@ class _FeedScreenState extends State<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);
|
||||
}
|
||||
|
||||
|
||||
+11
-28
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/news_source.dart';
|
||||
import '../models/source.dart';
|
||||
import '../state/app_state.dart';
|
||||
import 'article_screen.dart';
|
||||
import 'common.dart';
|
||||
import 'feed_screen.dart';
|
||||
|
||||
/// Reading that keeps for days, from the Sources that aren't News: one
|
||||
@@ -22,37 +23,23 @@ class LearnScreen extends StatelessWidget {
|
||||
builder: (context, _) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Learn'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: state.loading ? null : state.refresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
actions: [RefreshButton(state: state)],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (state.loading) const LinearProgressIndicator(),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: state.refresh,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 24),
|
||||
children: [for (final kind in kinds) ..._section(context, kind)],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
body: RefreshableList(
|
||||
state: state,
|
||||
list: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 24),
|
||||
children: [for (final kind in kinds) ..._kindSection(context, kind)],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _section(BuildContext context, SourceKind kind) {
|
||||
List<Widget> _kindSection(BuildContext context, SourceKind kind) {
|
||||
final theme = Theme.of(context);
|
||||
final muted = theme.colorScheme.onSurfaceVariant;
|
||||
final articles = state.articlesOf(kind);
|
||||
final hidden = state.hiddenCountOf(kind);
|
||||
final hasSources = state.enabledSources.any((s) => s.kind == kind);
|
||||
return [
|
||||
Padding(
|
||||
@@ -63,8 +50,7 @@ class LearnScreen extends StatelessWidget {
|
||||
Text(kind.label,
|
||||
style: theme.textTheme.titleMedium?.copyWith(color: theme.colorScheme.primary)),
|
||||
Text(
|
||||
'${articles.length} articles · last ${_describe(state.windowOf(kind))}'
|
||||
'${hidden > 0 ? ' · $hidden paid/ads hidden' : ''}',
|
||||
articleCountLine(state, kind),
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: muted),
|
||||
),
|
||||
],
|
||||
@@ -92,7 +78,4 @@ class LearnScreen extends StatelessWidget {
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static String _describe(Duration window) =>
|
||||
window.inHours < 72 ? '${window.inHours} h' : '${window.inDays} days';
|
||||
}
|
||||
@@ -2,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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -109,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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/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';
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:daily_tech_news/data/default_sources.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/models/source.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
NewsSource _byId(String id) => defaultSources.singleWhere((s) => s.id == id);
|
||||
Source _byId(String id) => defaultSources.singleWhere((s) => s.id == id);
|
||||
|
||||
void main() {
|
||||
test('the News sources on by default are the curated quality set', () {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:daily_tech_news/data/default_sources.dart';
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/briefing.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/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';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
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';
|
||||
@@ -73,7 +73,7 @@ void main() {
|
||||
expect(find.byType(SettingsScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('leaving the Sources tab after a change refreshes the feeds', (tester) async {
|
||||
testWidgets('leaving the Sources tab after a change refreshes all Sources', (tester) async {
|
||||
final feedService = FakeFeeds(feeds);
|
||||
final state = await launch(tester, feedService);
|
||||
await tester.pumpWidget(MaterialApp(home: HomeShell(state: state)));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:daily_tech_news/data/default_sources.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:daily_tech_news/services/feed_service.dart';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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/feed_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -17,7 +17,7 @@ class _SavedFeeds extends FeedService {
|
||||
final Map<String, String> fixtureByFeedUrl;
|
||||
|
||||
@override
|
||||
Future<List<Article>> fetchSource(NewsSource source, {String corsProxy = ''}) async {
|
||||
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 [
|
||||
@@ -47,7 +47,7 @@ void main() {
|
||||
substackNews.feedUrl: 'test/fixtures/ahead_of_ai_feed.xml',
|
||||
});
|
||||
|
||||
test('paid newsletter posts are hidden in the Learn tab and Today and counted as hidden',
|
||||
test('paid Substack posts are hidden in the Learn tab and Today and counted as hidden',
|
||||
() async {
|
||||
final state = await launchApp([interconnects, substackNews], feedService: savedFeeds());
|
||||
|
||||
@@ -61,7 +61,7 @@ void main() {
|
||||
expect(state.hiddenCountOf(SourceKind.news), 1);
|
||||
});
|
||||
|
||||
test('with "hide paid articles" off, paid newsletter posts show as before', () async {
|
||||
test('with "hide paid articles" off, paid Substack posts show as before', () async {
|
||||
final state = await launchApp([interconnects, substackNews], feedService: savedFeeds());
|
||||
|
||||
await state.updateSettings(state.settings.copyWith(hidePaid: false));
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
@@ -10,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',
|
||||
@@ -56,6 +56,31 @@ void main() {
|
||||
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>[];
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:daily_tech_news/data/default_sources.dart';
|
||||
import 'package:daily_tech_news/models/app_settings.dart';
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/briefing.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/models/source.dart';
|
||||
import 'package:daily_tech_news/services/summarizer.dart';
|
||||
import 'package:daily_tech_news/ui/briefing_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -62,6 +65,37 @@ void main() {
|
||||
expect(_titles(summarizer.briefingInput!), ['News from this morning']);
|
||||
});
|
||||
|
||||
test('the Briefing reads the shown News articles, not Learning, Fun or hidden ones', () async {
|
||||
final xkcd = testSource('xkcd', SourceKind.fun);
|
||||
final state = await launchApp([verge, bytebytego, xkcd], feeds: {
|
||||
verge.feedUrl: [('News from this morning', const Duration(hours: 2)),
|
||||
('Sponsored: Buy our phone', const Duration(hours: 3))],
|
||||
bytebytego.feedUrl: [('Explainer from today', const Duration(hours: 1))],
|
||||
xkcd.feedUrl: [('Comic from today', const Duration(hours: 1))],
|
||||
});
|
||||
|
||||
expect(_titles(state.briefingArticles), ['News from this morning']);
|
||||
});
|
||||
|
||||
testWidgets('the Briefing tab counts only the articles the Briefing is made from',
|
||||
(tester) async {
|
||||
final state = (await tester.runAsync(() async {
|
||||
final state = await launchApp([verge, bytebytego], feeds: {
|
||||
verge.feedUrl: [('News from this morning', const Duration(hours: 2))],
|
||||
bytebytego.feedUrl: [('Explainer from today', const Duration(hours: 1)),
|
||||
('Explainer from yesterday', const Duration(days: 1))],
|
||||
});
|
||||
await state.updateSettings(
|
||||
state.settings.copyWith(aiProvider: AiProvider.claude, claudeApiKey: 'key'));
|
||||
return state;
|
||||
}))!;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: BriefingScreen(state: state, openSettings: () {})));
|
||||
|
||||
expect(find.text('Generate from 1 articles'), findsOneWidget);
|
||||
});
|
||||
|
||||
test('News follows the window setting', () async {
|
||||
SharedPreferences.setMockInitialValues({'max_age_hours': 48});
|
||||
final state = await launchApp([verge], feeds: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:daily_tech_news/data/default_sources.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_service.dart';
|
||||
import 'package:daily_tech_news/services/storage.dart';
|
||||
import 'package:daily_tech_news/state/app_state.dart';
|
||||
@@ -9,12 +9,12 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
/// Returns no articles, so starting the app doesn't hit the network.
|
||||
class _NoFeeds extends FeedService {
|
||||
@override
|
||||
Future<FeedResult> fetchAll(List<NewsSource> sources,
|
||||
{required Duration Function(NewsSource source) maxAgeOf, String corsProxy = ''}) async =>
|
||||
Future<FeedResult> fetchAll(List<Source> sources,
|
||||
{required Duration Function(Source source) windowOf, String corsProxy = ''}) async =>
|
||||
const FeedResult([], {});
|
||||
}
|
||||
|
||||
NewsSource _source(String id, {bool onByDefault = false}) => NewsSource(
|
||||
Source _source(String id, {bool onByDefault = false}) => Source(
|
||||
id: id,
|
||||
name: id,
|
||||
feedUrl: 'https://example.com/$id.xml',
|
||||
@@ -31,7 +31,7 @@ const _learningOnByDefault = {
|
||||
};
|
||||
|
||||
/// Starts the app the way a launch does, against whatever is in preferences.
|
||||
Future<AppState> _launch(List<NewsSource> curatedSources) async {
|
||||
Future<AppState> _launch(List<Source> curatedSources) async {
|
||||
final state =
|
||||
AppState(await Storage.open(), feedService: _NoFeeds(), curatedSources: curatedSources);
|
||||
await state.init();
|
||||
|
||||
+12
-12
@@ -1,4 +1,4 @@
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
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';
|
||||
@@ -9,7 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'support/fake_app.dart';
|
||||
|
||||
NewsSource _source(String id, SourceKind kind, SourceLanguage language) => NewsSource(
|
||||
Source _source(String id, SourceKind kind, SourceLanguage language) => Source(
|
||||
id: id,
|
||||
name: id,
|
||||
feedUrl: 'https://example.com/$id.xml',
|
||||
@@ -30,7 +30,7 @@ void main() {
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
Future<AppState> launch(WidgetTester tester, List<NewsSource> sources) async =>
|
||||
Future<AppState> launch(WidgetTester tester, List<Source> sources) async =>
|
||||
(await tester.runAsync(() => launchApp(sources)))!;
|
||||
|
||||
Future<void> showSources(WidgetTester tester, AppState state) async {
|
||||
@@ -80,7 +80,7 @@ void main() {
|
||||
expect(find.text(german), findsNothing);
|
||||
});
|
||||
|
||||
Future<void> addFeed(WidgetTester tester, String name, {String? kind}) async {
|
||||
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);
|
||||
@@ -97,29 +97,29 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('a feed added without picking a kind is a News source', (tester) async {
|
||||
testWidgets('a Source added without picking a kind is a News source', (tester) async {
|
||||
final state = await launch(tester, [verge, bytebytego]);
|
||||
await showSources(tester, state);
|
||||
|
||||
await addFeed(tester, 'myfeed');
|
||||
await addSource(tester, 'myfeed');
|
||||
|
||||
expectInOrder(tester, [find.text('News'), find.text('myfeed'), find.text('Learning')]);
|
||||
});
|
||||
|
||||
testWidgets('a feed added as Learning shows up in the Learning section', (tester) async {
|
||||
testWidgets('a Source added as Learning shows up in the Learning section', (tester) async {
|
||||
final state = await launch(tester, [verge, bytebytego]);
|
||||
await showSources(tester, state);
|
||||
|
||||
await addFeed(tester, 'myblog', kind: 'Learning');
|
||||
await addSource(tester, 'myblog', kind: 'Learning');
|
||||
|
||||
expectInOrder(tester, [find.text('Learning'), find.text('myblog')]);
|
||||
});
|
||||
|
||||
testWidgets('a feed added as Fun gets a Fun section', (tester) async {
|
||||
testWidgets('a Source added as Fun gets a Fun section', (tester) async {
|
||||
final state = await launch(tester, [verge]);
|
||||
await showSources(tester, state);
|
||||
|
||||
await addFeed(tester, 'mycomic', kind: 'Fun');
|
||||
await addSource(tester, 'mycomic', kind: 'Fun');
|
||||
|
||||
expectInOrder(tester, [find.text('News'), find.text('Fun'), find.text('mycomic')]);
|
||||
});
|
||||
@@ -140,7 +140,7 @@ void main() {
|
||||
|
||||
await tester.tap(tab('Sources'));
|
||||
await tester.pump();
|
||||
await addFeed(tester, 'myblog', kind: 'Learning');
|
||||
await addSource(tester, 'myblog', kind: 'Learning');
|
||||
await tester.tap(tab('Learn'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -170,7 +170,7 @@ void main() {
|
||||
|
||||
await tester.tap(tab('Sources'));
|
||||
await tester.pump();
|
||||
await addFeed(tester, 'mycomic', kind: 'Fun');
|
||||
await addSource(tester, 'mycomic', kind: 'Fun');
|
||||
await tester.tap(tab('Learn'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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/feed_service.dart';
|
||||
import 'package:daily_tech_news/services/storage.dart';
|
||||
@@ -14,18 +14,18 @@ class FakeFeeds extends FeedService {
|
||||
/// Feed URL -> (title, age) of each article in the feed.
|
||||
final Map<String, List<(String, Duration)>> byFeedUrl;
|
||||
|
||||
/// How often the app refreshed its feeds.
|
||||
/// How often the app refreshed all Sources.
|
||||
int refreshes = 0;
|
||||
|
||||
@override
|
||||
Future<FeedResult> fetchAll(List<NewsSource> sources,
|
||||
{required Duration Function(NewsSource source) maxAgeOf, String corsProxy = ''}) {
|
||||
Future<FeedResult> fetchAll(List<Source> sources,
|
||||
{required Duration Function(Source source) windowOf, String corsProxy = ''}) {
|
||||
refreshes++;
|
||||
return super.fetchAll(sources, maxAgeOf: maxAgeOf, corsProxy: corsProxy);
|
||||
return super.fetchAll(sources, windowOf: windowOf, corsProxy: corsProxy);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Article>> fetchSource(NewsSource source, {String corsProxy = ''}) async {
|
||||
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)) {
|
||||
@@ -43,7 +43,7 @@ class FakeFeeds extends FeedService {
|
||||
}
|
||||
}
|
||||
|
||||
NewsSource testSource(String id, SourceKind kind) => NewsSource(
|
||||
Source testSource(String id, SourceKind kind) => Source(
|
||||
id: id,
|
||||
name: id,
|
||||
feedUrl: 'https://example.com/$id.xml',
|
||||
@@ -55,7 +55,7 @@ NewsSource testSource(String id, SourceKind kind) => NewsSource(
|
||||
|
||||
/// Starts the app the way a launch does, against whatever is in preferences.
|
||||
Future<AppState> launchApp(
|
||||
List<NewsSource> curatedSources, {
|
||||
List<Source> curatedSources, {
|
||||
Map<String, List<(String, Duration)>> feeds = const {},
|
||||
FeedService? feedService,
|
||||
Summarizer? summarizer,
|
||||
|
||||
Reference in new issue
Block a user