Add source kinds and the Learn tab with Learning sources (#2)
Every Source now has a kind: News, Learning or Fun. Curated Sources default to News, and custom Sources persist their kind (ones saved before default to News). Each kind has its own time window: News follows the 12/24/48 h setting, Learning reaches back 14 days, Fun 3 days. A new Learn tab between Briefing and Sources lists Learning articles with the same paid/sponsored filters and reader view as Today. It is built from a list of kinds so a Fun section can follow. Today and the Briefing only use News. ByteByteGo is the first Learning source, on by default. The home shell names its tabs instead of using hard-coded indexes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8c8ffca5dc
commit
64c4a11105
12 files changed
+498
-32
No files matched your search
@@ -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, 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);
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
class _NoFeeds extends FeedService {
|
||||
@override
|
||||
Future<FeedResult> fetchAll(List<NewsSource> 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'});
|
||||
});
|
||||
}
|
||||
@@ -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