diff --git a/lib/data/default_sources.dart b/lib/data/default_sources.dart index ce84ec1..c9c1928 100644 --- a/lib/data/default_sources.dart +++ b/lib/data/default_sources.dart @@ -184,6 +184,18 @@ const defaultSources = [ 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. diff --git a/lib/models/news_source.dart b/lib/models/news_source.dart index 63d47b5..d08a778 100644 --- a/lib/models/news_source.dart +++ b/lib/models/news_source.dart @@ -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, ); diff --git a/lib/services/feed_service.dart b/lib/services/feed_service.dart index 7d4c631..d73ac1b 100644 --- a/lib/services/feed_service.dart +++ b/lib/services/feed_service.dart @@ -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 fetchAll( List sources, { - required Duration maxAge, + required Duration Function(NewsSource source) maxAgeOf, String corsProxy = '', }) async { final errors = {}; @@ -43,12 +44,15 @@ class FeedService { } })); - final cutoff = DateTime.now().toUtc().subtract(maxAge); + final now = DateTime.now().toUtc(); final seen = {}; final articles =
[ - for (final list in results) + for (final (i, list) in results.indexed) for (final a in list) - if ((a.published == null || a.published!.isAfter(cutoff)) && seen.add(a.id)) a, + if ((a.published == null || + a.published!.isAfter(now.subtract(maxAgeOf(sources[i])))) && + seen.add(a.id)) + a, ]; articles.sort((a, b) { final pa = a.published, pb = b.published; diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index b1d022d..47b9b79 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -34,8 +34,10 @@ class AppState extends ChangeNotifier { FeedService? feedService, ArticleExtractor? extractor, List curatedSources = defaultSources, + Summarizer? summarizer, }) : _feeds = feedService ?? FeedService(), _curated = curatedSources, + _summarizerOverride = summarizer, consent = ConsentService(), paywall = PaywallService(_storage) { _extractor = extractor ?? ArticleExtractor(consent: consent); @@ -46,6 +48,9 @@ class AppState extends ChangeNotifier { /// The curated catalog; [defaultSources] outside of tests. final List _curated; + + /// Used instead of the AI set up in [settings]; for tests. + final Summarizer? _summarizerOverride; late final ArticleExtractor _extractor; final PaywallService paywall; @@ -56,7 +61,7 @@ class AppState extends ChangeNotifier { List customSources = []; Set enabledSourceIds = {}; - /// Everything the feeds returned; see [visibleArticles] for what is shown. + /// Everything the feeds returned; see [articlesOf] for what is shown. List
articles = []; Map feedErrors = {}; bool loading = false; @@ -77,10 +82,22 @@ class AppState extends ChangeNotifier { (settings.hidePaid && paywall.isPaid(a)) || (settings.hideSponsored && paywall.isSponsored(a)); - /// Articles after the paid/sponsored filters. The feed and the briefing use these. - List
get visibleArticles => articles.where((a) => !_isHidden(a)).toList(); + /// Articles from Sources 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(); - 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; + + /// 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 get allSources => [..._curated, ...customSources]; @@ -102,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; @@ -148,6 +165,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( @@ -156,6 +174,7 @@ class AppState extends ChangeNotifier { feedUrl: feedUrl, homepage: uri == null ? '' : '${uri.scheme}://${uri.host}', language: language, + kind: kind, isCustom: true, ); try { @@ -226,7 +245,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), @@ -270,7 +289,7 @@ class AppState extends ChangeNotifier { Future 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(); diff --git a/lib/ui/briefing_screen.dart b/lib/ui/briefing_screen.dart index f9a01aa..36c1c2b 100644 --- a/lib/ui/briefing_screen.dart +++ b/lib/ui/briefing_screen.dart @@ -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( diff --git a/lib/ui/feed_screen.dart b/lib/ui/feed_screen.dart index 2440006..cf2c307 100644 --- a/lib/ui/feed_screen.dart +++ b/lib/ui/feed_screen.dart @@ -25,13 +25,13 @@ class _FeedScreenState extends State { listenable: widget.state, builder: (context, _) { final state = widget.state; - final all = state.visibleArticles; + final all = state.articlesOf(SourceKind.news); final visible = all .where((a) => _language == null || a.source.language == _language) .where((a) => _sourceId == null || a.source.id == _sourceId) .toList(); final sourcesInFeed = {for (final a in all) a.source.id: a.source}; - final hidden = state.hiddenCount; + final hidden = state.hiddenCountOf(SourceKind.news); return Scaffold( appBar: AppBar( diff --git a/lib/ui/home_shell.dart b/lib/ui/home_shell.dart index 386bcf3..da21a4b 100644 --- a/lib/ui/home_shell.dart +++ b/lib/ui/home_shell.dart @@ -3,9 +3,13 @@ import 'package:flutter/material.dart'; import '../state/app_state.dart'; import 'briefing_screen.dart'; import 'feed_screen.dart'; +import 'learn_screen.dart'; import 'settings_screen.dart'; import 'sources_screen.dart'; +/// The tabs in the order they appear in the navigation bar. +enum _Tab { today, briefing, learn, sources, settings } + class HomeShell extends StatefulWidget { const HomeShell({super.key, required this.state}); @@ -16,20 +20,20 @@ class HomeShell extends StatefulWidget { } class _HomeShellState extends State { - int _index = 0; + _Tab _tab = _Tab.today; bool _sourcesChanged = false; - void _select(int index) { + void _select(_Tab tab) { // A text field left focused in Settings would otherwise get focus back // (and open the keyboard) when returning from an article. FocusManager.instance.primaryFocus?.unfocus(); // Source toggles only take effect on the next refresh; do it when the // user leaves the Sources tab. - if (_index == 2 && index != 2 && _sourcesChanged) { + if (_tab == _Tab.sources && tab != _Tab.sources && _sourcesChanged) { _sourcesChanged = false; widget.state.refresh(); } - setState(() => _index = index); + setState(() => _tab = tab); } @override @@ -37,20 +41,27 @@ class _HomeShellState extends State { final state = widget.state; return Scaffold( body: IndexedStack( - index: _index, + index: _tab.index, children: [ - FeedScreen(state: state), - BriefingScreen(state: state, openSettings: () => _select(3)), - SourcesScreen(state: state, onChanged: () => _sourcesChanged = true), - SettingsScreen(state: state), - ].indexed + for (final tab in _Tab.values) // Hidden tabs stay alive but must not hold or regain focus. - .map((e) => ExcludeFocus(excluding: e.$1 != _index, child: e.$2)) - .toList(), + ExcludeFocus( + excluding: tab != _tab, + child: switch (tab) { + _Tab.today => FeedScreen(state: state), + _Tab.briefing => BriefingScreen( + state: state, openSettings: () => _select(_Tab.settings)), + _Tab.learn => LearnScreen(state: state), + _Tab.sources => + SourcesScreen(state: state, onChanged: () => _sourcesChanged = true), + _Tab.settings => SettingsScreen(state: state), + }, + ), + ], ), bottomNavigationBar: NavigationBar( - selectedIndex: _index, - onDestinationSelected: _select, + selectedIndex: _tab.index, + onDestinationSelected: (i) => _select(_Tab.values[i]), destinations: const [ NavigationDestination( icon: Icon(Icons.newspaper_outlined), @@ -60,6 +71,10 @@ class _HomeShellState extends State { icon: Icon(Icons.auto_awesome_outlined), selectedIcon: Icon(Icons.auto_awesome), label: 'Briefing'), + NavigationDestination( + icon: Icon(Icons.school_outlined), + selectedIcon: Icon(Icons.school), + label: 'Learn'), NavigationDestination( icon: Icon(Icons.rss_feed_outlined), selectedIcon: Icon(Icons.rss_feed), diff --git a/lib/ui/learn_screen.dart b/lib/ui/learn_screen.dart new file mode 100644 index 0000000..7822749 --- /dev/null +++ b/lib/ui/learn_screen.dart @@ -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 _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'; +} diff --git a/test/learn_tab_test.dart b/test/learn_tab_test.dart new file mode 100644 index 0000000..fd0e8d6 --- /dev/null +++ b/test/learn_tab_test.dart @@ -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 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); + }); +} diff --git a/test/source_kinds_test.dart b/test/source_kinds_test.dart new file mode 100644 index 0000000..f1cbc4f --- /dev/null +++ b/test/source_kinds_test.dart @@ -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
? briefingInput; + + @override + String get name => 'test'; + + @override + Future summarizeArticle(Article article, String text, SourceLanguage language) async => + ''; + + @override + Future createBriefing(List
articles, SourceLanguage language) async { + briefingInput = articles; + return DailyBriefing(createdAt: DateTime.now(), intro: '', topics: const [], generatedBy: name); + } +} + +List _titles(List
articles) => articles.map((a) => a.title).toList(); + +void main() { + final verge = testSource('verge', SourceKind.news); + final bytebytego = testSource('bytebytego', SourceKind.learning); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('News uses the 24 h setting and Learning reaches back 14 days', () async { + final state = await launchApp([verge, bytebytego], feeds: { + verge.feedUrl: [('News from this morning', const Duration(hours: 2)), + ('News from yesterday', const Duration(hours: 30))], + bytebytego.feedUrl: [('Explainer from 2 days ago', const Duration(days: 2)), + ('Explainer from 13 days ago', const Duration(days: 13)), + ('Explainer from 15 days ago', const Duration(days: 15))], + }); + + expect(_titles(state.articlesOf(SourceKind.news)), ['News from this morning']); + expect(_titles(state.articlesOf(SourceKind.learning)), + ['Explainer from 2 days ago', 'Explainer from 13 days ago']); + }); + + test('the Briefing is generated from News articles only', () async { + final summarizer = _RecordingSummarizer(); + final state = await launchApp([verge, bytebytego], feeds: { + verge.feedUrl: [('News from this morning', const Duration(hours: 2))], + bytebytego.feedUrl: [('Explainer from today', const Duration(hours: 1))], + }, summarizer: summarizer); + + await state.generateBriefing(); + + expect(_titles(summarizer.briefingInput!), ['News from this morning']); + }); + + test('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, hasLength(legacySeenSourceIds.length)); + 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); + }); +} diff --git a/test/source_selection_test.dart b/test/source_selection_test.dart index 8ce401a..95572c4 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 maxAge, String corsProxy = ''}) async => + {required Duration Function(NewsSource source) maxAgeOf, String corsProxy = ''}) async => const FeedResult([], {}); } @@ -84,6 +84,7 @@ void main() { final newSource = _source('new-source', onByDefault: true); final state = await _launch([...defaultSources, newSource]); - expect(state.enabledSourceIds, {'heise', 'verge', 'wired', 'new-source'}); + // ByteByteGo came after the legacy catalog, so it is new to this install too. + expect(state.enabledSourceIds, {'heise', 'verge', 'wired', 'bytebytego', 'new-source'}); }); } diff --git a/test/support/fake_app.dart b/test/support/fake_app.dart new file mode 100644 index 0000000..61c3279 --- /dev/null +++ b/test/support/fake_app.dart @@ -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> byFeedUrl; + + /// How often the app refreshed its feeds. + int refreshes = 0; + + @override + Future fetchAll(List sources, + {required Duration Function(NewsSource source) maxAgeOf, String corsProxy = ''}) { + refreshes++; + return super.fetchAll(sources, maxAgeOf: maxAgeOf, corsProxy: corsProxy); + } + + @override + Future> 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 launchApp( + List curatedSources, { + Map> 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; +}