diff --git a/lib/models/source.dart b/lib/models/source.dart index b55d28e..f62ad78 100644 --- a/lib/models/source.dart +++ b/lib/models/source.dart @@ -11,13 +11,28 @@ 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]; see GLOSSARY.md. diff --git a/lib/services/feed_service.dart b/lib/services/feed_service.dart index 5dce82e..e3af97c 100644 --- a/lib/services/feed_service.dart +++ b/lib/services/feed_service.dart @@ -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 fetchAll( List sources, { - required Duration Function(Source source) maxAgeOf, + required Duration Function(Source source) windowOf, String corsProxy = '', }) async { final errors = {}; @@ -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, ]; diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 305888e..111597a 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -82,22 +82,27 @@ 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
_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
articlesOf(SourceKind kind) => - articles.where((a) => a.source.kind == kind && !_isHidden(a)).toList(); + /// uses News, the Learn tab the other kinds. + List
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
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 get allSources => [..._curated, ...customSources]; @@ -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; @@ -292,7 +297,7 @@ class AppState extends ChangeNotifier { Future 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(); diff --git a/lib/ui/article_screen.dart b/lib/ui/article_screen.dart index 3bb2079..1dc5709 100644 --- a/lib/ui/article_screen.dart +++ b/lib/ui/article_screen.dart @@ -6,7 +6,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import '../models/article.dart'; -import '../models/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 []; - // 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) { diff --git a/lib/ui/briefing_screen.dart b/lib/ui/briefing_screen.dart index b8a69d6..32fa414 100644 --- a/lib/ui/briefing_screen.dart +++ b/lib/ui/briefing_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import '../models/briefing.dart'; -import '../models/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( diff --git a/test/source_kinds_test.dart b/test/source_kinds_test.dart index bb49562..352912a 100644 --- a/test/source_kinds_test.dart +++ b/test/source_kinds_test.dart @@ -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/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: { diff --git a/test/source_selection_test.dart b/test/source_selection_test.dart index 8578bd0..f2027e1 100644 --- a/test/source_selection_test.dart +++ b/test/source_selection_test.dart @@ -10,7 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart'; class _NoFeeds extends FeedService { @override Future fetchAll(List sources, - {required Duration Function(Source source) maxAgeOf, String corsProxy = ''}) async => + {required Duration Function(Source source) windowOf, String corsProxy = ''}) async => const FeedResult([], {}); } diff --git a/test/support/fake_app.dart b/test/support/fake_app.dart index 4b659f3..6fe0d65 100644 --- a/test/support/fake_app.dart +++ b/test/support/fake_app.dart @@ -19,9 +19,9 @@ class FakeFeeds extends FeedService { @override Future fetchAll(List sources, - {required Duration Function(Source source) maxAgeOf, String corsProxy = ''}) { + {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