Compare commits
5
Commits
main
...
a6b8d8b537
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/
|
||||
@@ -33,7 +33,7 @@ or write a daily briefing. It's meant to replace the n8n → Discord workflow.
|
||||
same request as Golem's own "Zustimmen" button (`/abo/setconsentcookie.php`,
|
||||
valid for a day) and reuses the cookie for reader view and the in-app page.
|
||||
With it off, the app shows the teaser and an "Open website anyway" option.
|
||||
- **Sources**: 21 curated feeds (12 German, 9 English), toggle each one, or add any
|
||||
- **Sources**: 23 curated feeds (13 German, 10 English), toggle each one, or add any
|
||||
RSS/Atom feed (it's validated before being added).
|
||||
- **Dark mode**: System / Light / Dark in Settings (applies instantly). The
|
||||
original-page view follows it too: when the phone is dark, sites use their own
|
||||
@@ -145,34 +145,40 @@ existing AI nodes.
|
||||
## News sources
|
||||
|
||||
Every feed URL below was checked to load and parse. The ★ sources are enabled by
|
||||
default.
|
||||
default: a small set of high-signal News sources. The others stay in the catalog
|
||||
to switch on in the Sources tab.
|
||||
|
||||
| | Source | Feed | Focus |
|
||||
|---|---|---|---|
|
||||
| 🇩🇪 ★ | heise online | `heise.de/rss/heise-atom.xml` | Broad IT news, high volume (~150/day) |
|
||||
| 🇩🇪 ★ | Golem.de | `rss.golem.de/rss.php?feed=ATOM1.0` | IT news for professionals |
|
||||
| 🇩🇪 ★ | t3n | `t3n.de/rss.xml` | Digital business, startups, AI |
|
||||
| 🇩🇪 ★ | ComputerBase | `computerbase.de/rss/news.xml` | Hardware, software, gaming |
|
||||
| 🇩🇪 ★ | The Decoder | `the-decoder.de/feed/` | AI news: models, research, industry |
|
||||
| 🇩🇪 | t3n | `t3n.de/rss.xml` | Digital business, startups, AI |
|
||||
| 🇩🇪 | ComputerBase | `computerbase.de/rss/news.xml` | Hardware, software, gaming |
|
||||
| 🇩🇪 | SPIEGEL Netzwelt | `spiegel.de/netzwelt/index.rss` | Mainstream news magazine view |
|
||||
| 🇩🇪 | ZEIT Digital | `newsfeed.zeit.de/digital/index` | Digital policy and society |
|
||||
| 🇩🇪 | netzpolitik.org | `netzpolitik.org/feed/` | Digital rights, privacy |
|
||||
| 🇩🇪 | heise Security | `heise.de/security/rss/news-atom.xml` | Security vulnerabilities |
|
||||
| 🇩🇪 | heise Developer | `heise.de/developer/rss/news-atom.xml` | Software development |
|
||||
| 🇩🇪 | heise Security | `heise.de/security/rss/news-atom.xml` | Security section of heise online (turn heise online off to use it) |
|
||||
| 🇩🇪 | heise Developer | `heise.de/developer/rss/news-atom.xml` | Developer section of heise online (turn heise online off to use it) |
|
||||
| 🇩🇪 | Caschys Blog | `stadt-bremerhaven.de/feed/` | Consumer tech, smartphones |
|
||||
| 🇩🇪 | derStandard Web | `derstandard.at/rss/web` | Austrian daily's tech section |
|
||||
| 🇩🇪 | Borns IT- und Windows-Blog | `borncity.com/blog/feed/` | Windows, patches, admin topics |
|
||||
| 🇬🇧 ★ | The Verge | `theverge.com/rss/index.xml` | Consumer tech, policy |
|
||||
| 🇬🇧 | The Verge | `theverge.com/rss/index.xml` | Consumer tech, policy |
|
||||
| 🇬🇧 ★ | Ars Technica | `feeds.arstechnica.com/arstechnica/index` | In-depth tech and science |
|
||||
| 🇬🇧 ★ | TechCrunch | `techcrunch.com/feed/` | Startups, funding |
|
||||
| 🇬🇧 ★ | Hacker News | `news.ycombinator.com/rss` | Front page links |
|
||||
| 🇬🇧 | TechCrunch | `techcrunch.com/feed/` | Startups, funding |
|
||||
| 🇬🇧 ★ | Hacker News | `hnrss.org/frontpage?points=150` | Front page stories with 150+ points |
|
||||
| 🇬🇧 ★ | Techmeme | `techmeme.com/feed.xml` | The day's top tech stories, picked by editors |
|
||||
| 🇬🇧 ★ | Lobsters | `lobste.rs/rss` | Computing links, voted by a programmer community |
|
||||
| 🇬🇧 ★ | Simon Willison | `simonwillison.net/atom/entries/` | LLMs and AI tools, tried out hands-on |
|
||||
| 🇬🇧 | MIT Technology Review | `technologyreview.com/feed/` | AI, biotech, climate |
|
||||
| 🇬🇧 | WIRED | `wired.com/feed/rss` | Tech and culture |
|
||||
| 🇬🇧 | Engadget | `engadget.com/rss.xml` | Gadgets |
|
||||
| 🇬🇧 | The Register | `theregister.com/headlines.atom` | Enterprise IT |
|
||||
| 🇬🇧 | BleepingComputer | `bleepingcomputer.com/feed/` | Security, malware |
|
||||
|
||||
Reader view works for 20 of the 21 sources. ZEIT Digital blocks non-browser
|
||||
requests, so the app shows the teaser and offers the original page.
|
||||
Reader view works for the publishers' own articles except ZEIT Digital, which
|
||||
blocks non-browser requests, so the app shows the teaser and offers the original
|
||||
page. Hacker News and Lobsters link to articles all over the web, so reader view
|
||||
depends on the linked site. Techmeme links to its own summary page, which only
|
||||
has the teaser; the original page links on to the full story.
|
||||
|
||||
## Project layout
|
||||
|
||||
|
||||
@@ -22,6 +22,15 @@ const defaultSources = <NewsSource>[
|
||||
description: 'IT-News für Profis',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'the-decoder',
|
||||
name: 'The Decoder',
|
||||
feedUrl: 'https://the-decoder.de/feed/',
|
||||
homepage: 'https://the-decoder.de',
|
||||
language: SourceLanguage.de,
|
||||
description: 'KI-News: Modelle, Forschung, Branche',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 't3n',
|
||||
name: 't3n',
|
||||
@@ -29,7 +38,6 @@ const defaultSources = <NewsSource>[
|
||||
homepage: 'https://t3n.de',
|
||||
language: SourceLanguage.de,
|
||||
description: 'Digitales Business, Startups, KI',
|
||||
enabledByDefault: true,
|
||||
checkPaywallOnPage: true,
|
||||
),
|
||||
NewsSource(
|
||||
@@ -39,7 +47,6 @@ const defaultSources = <NewsSource>[
|
||||
homepage: 'https://www.computerbase.de',
|
||||
language: SourceLanguage.de,
|
||||
description: 'Hardware, Software, Gaming',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'spiegel-netzwelt',
|
||||
@@ -72,7 +79,7 @@ const defaultSources = <NewsSource>[
|
||||
feedUrl: 'https://www.heise.de/security/rss/news-atom.xml',
|
||||
homepage: 'https://www.heise.de/security/',
|
||||
language: SourceLanguage.de,
|
||||
description: 'Sicherheitslücken und Security-News',
|
||||
description: 'Security-Teil von heise online (heise online ausschalten, um ihn einzeln zu lesen)',
|
||||
),
|
||||
NewsSource(
|
||||
id: 'heise-developer',
|
||||
@@ -80,7 +87,7 @@ const defaultSources = <NewsSource>[
|
||||
feedUrl: 'https://www.heise.de/developer/rss/news-atom.xml',
|
||||
homepage: 'https://www.heise.de/developer/',
|
||||
language: SourceLanguage.de,
|
||||
description: 'Softwareentwicklung',
|
||||
description: 'Developer-Teil von heise online (heise online ausschalten, um ihn einzeln zu lesen)',
|
||||
),
|
||||
NewsSource(
|
||||
id: 'caschy',
|
||||
@@ -115,7 +122,6 @@ const defaultSources = <NewsSource>[
|
||||
homepage: 'https://www.theverge.com',
|
||||
language: SourceLanguage.en,
|
||||
description: 'Consumer tech, culture, policy',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'arstechnica',
|
||||
@@ -133,15 +139,41 @@ const defaultSources = <NewsSource>[
|
||||
homepage: 'https://techcrunch.com',
|
||||
language: SourceLanguage.en,
|
||||
description: 'Startups, funding, big tech',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'hackernews',
|
||||
name: 'Hacker News',
|
||||
feedUrl: 'https://news.ycombinator.com/rss',
|
||||
feedUrl: 'https://hnrss.org/frontpage?points=150',
|
||||
homepage: 'https://news.ycombinator.com',
|
||||
language: SourceLanguage.en,
|
||||
description: 'Front page links, ranked by readers',
|
||||
description: 'Front page stories with 150+ points',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'techmeme',
|
||||
name: 'Techmeme',
|
||||
feedUrl: 'https://www.techmeme.com/feed.xml',
|
||||
homepage: 'https://www.techmeme.com',
|
||||
language: SourceLanguage.en,
|
||||
description: "The day's top tech stories, picked by editors",
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'lobsters',
|
||||
name: 'Lobsters',
|
||||
feedUrl: 'https://lobste.rs/rss',
|
||||
homepage: 'https://lobste.rs',
|
||||
language: SourceLanguage.en,
|
||||
description: 'Computing links, voted by a programmer community',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'simonwillison',
|
||||
name: 'Simon Willison',
|
||||
feedUrl: 'https://simonwillison.net/atom/entries/',
|
||||
homepage: 'https://simonwillison.net',
|
||||
language: SourceLanguage.en,
|
||||
description: 'LLMs and AI tools, tried out hands-on',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
@@ -152,22 +184,6 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.en,
|
||||
description: 'AI, biotech, climate tech',
|
||||
),
|
||||
NewsSource(
|
||||
id: 'wired',
|
||||
name: 'WIRED',
|
||||
feedUrl: 'https://www.wired.com/feed/rss',
|
||||
homepage: 'https://www.wired.com',
|
||||
language: SourceLanguage.en,
|
||||
description: 'Tech, science and culture',
|
||||
),
|
||||
NewsSource(
|
||||
id: 'engadget',
|
||||
name: 'Engadget',
|
||||
feedUrl: 'https://www.engadget.com/rss.xml',
|
||||
homepage: 'https://www.engadget.com',
|
||||
language: SourceLanguage.en,
|
||||
description: 'Gadgets and consumer electronics',
|
||||
),
|
||||
NewsSource(
|
||||
id: 'theregister',
|
||||
name: 'The Register',
|
||||
@@ -184,4 +200,25 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.en,
|
||||
description: 'Security and malware news',
|
||||
),
|
||||
|
||||
// --- Learning -----------------------------------------------------------
|
||||
NewsSource(
|
||||
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,
|
||||
),
|
||||
];
|
||||
|
||||
/// 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',
|
||||
};
|
||||
@@ -8,6 +8,19 @@ enum SourceLanguage {
|
||||
final String flag;
|
||||
}
|
||||
|
||||
/// 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');
|
||||
|
||||
const SourceKind(this.label);
|
||||
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// A Source of any [kind]; the class name predates the kinds.
|
||||
class NewsSource {
|
||||
const NewsSource({
|
||||
required this.id,
|
||||
@@ -15,6 +28,7 @@ class NewsSource {
|
||||
required this.feedUrl,
|
||||
required this.homepage,
|
||||
required this.language,
|
||||
this.kind = SourceKind.news,
|
||||
this.description = '',
|
||||
this.enabledByDefault = false,
|
||||
this.isCustom = false,
|
||||
@@ -26,6 +40,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,6 +55,7 @@ class NewsSource {
|
||||
'feedUrl': feedUrl,
|
||||
'homepage': homepage,
|
||||
'language': language.name,
|
||||
'kind': kind.name,
|
||||
'description': description,
|
||||
};
|
||||
|
||||
@@ -49,6 +65,8 @@ class NewsSource {
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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 age
|
||||
/// [maxAgeOf] 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,
|
||||
required Duration Function(NewsSource source) maxAgeOf,
|
||||
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(maxAgeOf(sources[i])))) &&
|
||||
seen.add(a.id))
|
||||
a,
|
||||
];
|
||||
articles.sort((a, b) {
|
||||
final pa = a.published, pb = b.published;
|
||||
|
||||
@@ -74,6 +74,13 @@ class Storage {
|
||||
Future<void> saveEnabledSourceIds(Set<String> ids) =>
|
||||
_prefs.setStringList('enabled_sources', ids.toList());
|
||||
|
||||
/// Curated sources the app has already offered, so each one that is on by
|
||||
/// default switches on only once. Null means "never saved".
|
||||
Set<String>? loadSeenSourceIds() => _prefs.getStringList('seen_sources')?.toSet();
|
||||
|
||||
Future<void> saveSeenSourceIds(Set<String> ids) =>
|
||||
_prefs.setStringList('seen_sources', ids.toList());
|
||||
|
||||
List<NewsSource> loadCustomSources() {
|
||||
final raw = _prefs.getString('custom_sources');
|
||||
if (raw == null) return [];
|
||||
|
||||
+59
-12
@@ -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<NewsSource> 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<NewsSource> _curated;
|
||||
|
||||
/// Used instead of the AI set up in [settings]; for tests.
|
||||
final Summarizer? _summarizerOverride;
|
||||
late final ArticleExtractor _extractor;
|
||||
final PaywallService paywall;
|
||||
|
||||
@@ -48,7 +61,7 @@ class AppState extends ChangeNotifier {
|
||||
List<NewsSource> 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,12 +82,24 @@ 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 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();
|
||||
|
||||
int get hiddenCount => articles.where(_isHidden).length;
|
||||
/// 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;
|
||||
|
||||
List<NewsSource> get allSources => [...defaultSources, ...customSources];
|
||||
/// 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),
|
||||
};
|
||||
|
||||
List<NewsSource> get allSources => [..._curated, ...customSources];
|
||||
|
||||
List<NewsSource> get enabledSources =>
|
||||
allSources.where((s) => enabledSourceIds.contains(s.id)).toList();
|
||||
@@ -82,8 +107,7 @@ class AppState extends ChangeNotifier {
|
||||
Future<void> init() async {
|
||||
settings = await _storage.loadSettings();
|
||||
customSources = _storage.loadCustomSources();
|
||||
enabledSourceIds = _storage.loadEnabledSourceIds() ??
|
||||
defaultSources.where((s) => s.enabledByDefault).map((s) => s.id).toSet();
|
||||
await _enableNewDefaultSources();
|
||||
briefings = await _storage.loadBriefingHistory();
|
||||
notifyListeners();
|
||||
await refresh();
|
||||
@@ -95,7 +119,7 @@ class AppState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
final result = await _feeds.fetchAll(
|
||||
enabledSources,
|
||||
maxAge: Duration(hours: settings.maxAgeHours),
|
||||
maxAgeOf: (source) => windowOf(source.kind),
|
||||
corsProxy: settings.corsProxy,
|
||||
);
|
||||
articles = result.articles;
|
||||
@@ -111,6 +135,27 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
// --- Sources -------------------------------------------------------------
|
||||
|
||||
/// Switches on curated sources that are on by default the first time the
|
||||
/// app sees them, so they also reach installs with saved toggles. Sources
|
||||
/// seen before keep whatever the user chose. Saved ids of Sources that are
|
||||
/// no longer in the catalog are dropped.
|
||||
Future<void> _enableNewDefaultSources() async {
|
||||
final saved = _storage.loadEnabledSourceIds();
|
||||
final seen = _storage.loadSeenSourceIds() ??
|
||||
(saved == null ? const <String>{} : legacySeenSourceIds);
|
||||
final unseen = _curated.where((s) => !seen.contains(s.id)).toList();
|
||||
final known = {for (final s in allSources) s.id};
|
||||
enabledSourceIds = {
|
||||
...?saved?.where(known.contains),
|
||||
for (final s in unseen)
|
||||
if (s.enabledByDefault) s.id,
|
||||
};
|
||||
final dropped = saved != null && saved.any((id) => !known.contains(id));
|
||||
if (unseen.isEmpty && !dropped) return;
|
||||
await _storage.saveSeenSourceIds({...seen, ...unseen.map((s) => s.id)});
|
||||
await _storage.saveEnabledSourceIds(enabledSourceIds);
|
||||
}
|
||||
|
||||
Future<void> setSourceEnabled(NewsSource source, bool enabled) async {
|
||||
enabled ? enabledSourceIds.add(source.id) : enabledSourceIds.remove(source.id);
|
||||
notifyListeners();
|
||||
@@ -123,6 +168,7 @@ 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(
|
||||
@@ -131,6 +177,7 @@ class AppState extends ChangeNotifier {
|
||||
feedUrl: feedUrl,
|
||||
homepage: uri == null ? '' : '${uri.scheme}://${uri.host}',
|
||||
language: language,
|
||||
kind: kind,
|
||||
isCustom: true,
|
||||
);
|
||||
try {
|
||||
@@ -201,7 +248,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 +292,7 @@ class AppState extends ChangeNotifier {
|
||||
Future<void> generateBriefing() async {
|
||||
final summarizer = _summarizer;
|
||||
if (summarizer == null || briefingLoading) return;
|
||||
final articles = visibleArticles;
|
||||
final articles = articlesOf(SourceKind.news);
|
||||
if (articles.isEmpty) {
|
||||
briefingError = 'No articles loaded yet. Pull to refresh the feed first.';
|
||||
notifyListeners();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -59,7 +60,7 @@ class BriefingScreen extends StatelessWidget {
|
||||
if (state.briefingLoading) {
|
||||
return EmptyState(
|
||||
icon: Icons.hourglass_top,
|
||||
title: 'Reading ${state.visibleArticles.length} headlines…',
|
||||
title: 'Reading ${state.articlesOf(SourceKind.news).length} headlines…',
|
||||
message: 'This usually takes 10-60 seconds.',
|
||||
action: const SizedBox(width: 160, child: LinearProgressIndicator()),
|
||||
);
|
||||
@@ -69,7 +70,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.articlesOf(SourceKind.news).length} articles'),
|
||||
);
|
||||
if (briefing == null) {
|
||||
return EmptyState(
|
||||
|
||||
@@ -25,13 +25,13 @@ 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;
|
||||
final hidden = state.hiddenCountOf(SourceKind.news);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
|
||||
+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,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/news_source.dart';
|
||||
import '../state/app_state.dart';
|
||||
import 'article_screen.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];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: state,
|
||||
builder: (context, _) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Learn'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: state.loading ? null : state.refresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
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)],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _section(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(
|
||||
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(
|
||||
'${articles.length} articles · last ${_describe(state.windowOf(kind))}'
|
||||
'${hidden > 0 ? ' · $hidden paid/ads hidden' : ''}',
|
||||
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),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static String _describe(Duration window) =>
|
||||
window.inHours < 72 ? '${window.inHours} h' : '${window.inDays} days';
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:daily_tech_news/data/default_sources.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
NewsSource _byId(String id) => defaultSources.singleWhere((s) => s.id == id);
|
||||
|
||||
void main() {
|
||||
test('the News sources on by default are the curated quality set', () {
|
||||
final onByDefault = {
|
||||
for (final s in defaultSources)
|
||||
if (s.enabledByDefault && s.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('every Source has its own id', () {
|
||||
final ids = defaultSources.map((s) => s.id).toList();
|
||||
|
||||
expect(ids.toSet().length, ids.length);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:daily_tech_news/models/news_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 the feeds', (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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:daily_tech_news/data/default_sources.dart';
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/briefing.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/services/summarizer.dart';
|
||||
import 'package: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('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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:daily_tech_news/data/default_sources.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/services/feed_service.dart';
|
||||
import 'package:daily_tech_news/services/storage.dart';
|
||||
import 'package:daily_tech_news/state/app_state.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Returns no articles, so starting the app doesn't hit the network.
|
||||
class _NoFeeds extends FeedService {
|
||||
@override
|
||||
Future<FeedResult> fetchAll(List<NewsSource> sources,
|
||||
{required Duration Function(NewsSource source) maxAgeOf, String corsProxy = ''}) async =>
|
||||
const FeedResult([], {});
|
||||
}
|
||||
|
||||
NewsSource _source(String id, {bool onByDefault = false}) => NewsSource(
|
||||
id: id,
|
||||
name: id,
|
||||
feedUrl: 'https://example.com/$id.xml',
|
||||
homepage: 'https://example.com',
|
||||
language: SourceLanguage.en,
|
||||
enabledByDefault: onByDefault,
|
||||
);
|
||||
|
||||
/// Starts the app the way a launch does, against whatever is in preferences.
|
||||
Future<AppState> _launch(List<NewsSource> curatedSources) async {
|
||||
final state =
|
||||
AppState(await Storage.open(), feedService: _NoFeeds(), curatedSources: curatedSources);
|
||||
await state.init();
|
||||
return state;
|
||||
}
|
||||
|
||||
void main() {
|
||||
final heise = _source('heise', onByDefault: true);
|
||||
final golem = _source('golem', onByDefault: true);
|
||||
final wired = _source('wired');
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test('a fresh install starts with exactly the on-by-default Sources', () async {
|
||||
final state = await _launch([heise, golem, wired]);
|
||||
|
||||
expect(state.enabledSourceIds, {'heise', 'golem'});
|
||||
});
|
||||
|
||||
test('a newly added on-by-default Source shows up enabled after the update', () async {
|
||||
final before = await _launch([heise, golem, wired]);
|
||||
await before.setSourceEnabled(wired, true);
|
||||
|
||||
final verge = _source('verge', onByDefault: true);
|
||||
final after = await _launch([heise, golem, wired, verge]);
|
||||
|
||||
expect(after.enabledSourceIds, {'heise', 'golem', 'wired', 'verge'});
|
||||
});
|
||||
|
||||
test('a Source the user switched off stays off across updates', () async {
|
||||
final before = await _launch([heise, golem, wired]);
|
||||
await before.setSourceEnabled(golem, false);
|
||||
|
||||
final verge = _source('verge', onByDefault: true);
|
||||
await _launch([heise, golem, wired, verge]);
|
||||
final later = await _launch([heise, golem, wired, verge]);
|
||||
|
||||
expect(later.enabledSourceIds, {'heise', 'verge'});
|
||||
});
|
||||
|
||||
test('a newly added Source that is off by default stays off', () async {
|
||||
final before = await _launch([heise, golem, wired]);
|
||||
await before.setSourceEnabled(wired, true);
|
||||
|
||||
final engadget = _source('engadget');
|
||||
final after = await _launch([heise, golem, wired, engadget]);
|
||||
|
||||
expect(after.enabledSourceIds, {'heise', 'golem', 'wired'});
|
||||
});
|
||||
|
||||
test('an install from before the app remembered seen Sources keeps its toggles', () async {
|
||||
// Toggles saved by an earlier version: golem, t3n and others switched off.
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'enabled_sources': ['heise', 'verge', 'mit-tr'],
|
||||
});
|
||||
|
||||
final newSource = _source('new-source', onByDefault: true);
|
||||
final state = await _launch([...defaultSources, newSource]);
|
||||
|
||||
expect(state.enabledSourceIds, {
|
||||
'heise', 'verge', 'mit-tr', 'new-source',
|
||||
// Added to the catalog later, on by default.
|
||||
'techmeme', 'lobsters', 'the-decoder', 'simonwillison', 'bytebytego',
|
||||
});
|
||||
});
|
||||
|
||||
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', 'bytebytego',
|
||||
});
|
||||
expect(state.enabledSources.map((s) => s.id), isNot(contains('wired')));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/services/feed_service.dart';
|
||||
import 'package:daily_tech_news/services/storage.dart';
|
||||
import 'package:daily_tech_news/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 its feeds.
|
||||
int refreshes = 0;
|
||||
|
||||
@override
|
||||
Future<FeedResult> fetchAll(List<NewsSource> sources,
|
||||
{required Duration Function(NewsSource source) maxAgeOf, String corsProxy = ''}) {
|
||||
refreshes++;
|
||||
return super.fetchAll(sources, maxAgeOf: maxAgeOf, corsProxy: corsProxy);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Article>> fetchSource(NewsSource 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),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
NewsSource testSource(String id, SourceKind kind) => NewsSource(
|
||||
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<NewsSource> curatedSources, {
|
||||
Map<String, List<(String, Duration)>> feeds = const {},
|
||||
FeedService? feedService,
|
||||
Summarizer? summarizer,
|
||||
}) async {
|
||||
final state = AppState(await Storage.open(),
|
||||
feedService: feedService ?? FakeFeeds(feeds),
|
||||
curatedSources: curatedSources,
|
||||
summarizer: summarizer);
|
||||
await state.init();
|
||||
return state;
|
||||
}
|
||||
Reference in new issue
Block a user