Source kinds, Learn tab and catalog update #9

Open
Cedric wants to merge 18 commits from feature/source-kinds into main
pull from: feature/source-kinds
10 changed files with 387 additions and 20 deletions
Showing only changes of commit 8240a767ef - Show all commits

No files matched your search

+15
View File
@@ -180,6 +180,21 @@ 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.
## Fun sources
Tech humour for the Fun section of the Learn tab, which reaches back 3 days. Fun
articles never show up in Today or the Briefing. ★ = enabled by default.
| | Source | Feed | Focus |
|---|---|---|---|
| 🇬🇧 ★ | xkcd | `xkcd.com/atom.xml` | Webcomic of romance, sarcasm, math and language |
| 🇬🇧 ★ | The Daily WTF | `thedailywtf.com/rss` | IT horror stories, bad code and error messages |
| 🇬🇧 | r/ProgrammerHumor | `reddit.com/r/ProgrammerHumor/top/.rss?t=day` | The day's top programming memes on Reddit |
Reader view shows comics and memes whole instead of as a cropped banner: xkcd
with its title text underneath, The Daily WTF's Error'd with its screenshots.
Reddit blocks reader view, so a meme shows the picture from the feed.
## Project layout
```
+31
View File
@@ -212,6 +212,37 @@ const defaultSources = <NewsSource>[
description: 'System design explained with diagrams',
enabledByDefault: true,
),
// --- Fun ----------------------------------------------------------------
NewsSource(
id: 'xkcd',
name: 'xkcd',
feedUrl: 'https://xkcd.com/atom.xml',
homepage: 'https://xkcd.com',
language: SourceLanguage.en,
kind: SourceKind.fun,
description: 'Webcomic of romance, sarcasm, math and language',
enabledByDefault: true,
),
NewsSource(
id: 'thedailywtf',
name: 'The Daily WTF',
feedUrl: 'https://thedailywtf.com/rss',
homepage: 'https://thedailywtf.com',
language: SourceLanguage.en,
kind: SourceKind.fun,
description: 'IT horror stories, bad code and error messages',
enabledByDefault: true,
),
NewsSource(
id: 'programmerhumor',
name: 'r/ProgrammerHumor',
feedUrl: 'https://www.reddit.com/r/ProgrammerHumor/top/.rss?t=day',
homepage: 'https://www.reddit.com/r/ProgrammerHumor/',
language: SourceLanguage.en,
kind: SourceKind.fun,
description: "The day's top programming memes on Reddit",
),
];
/// The curated sources before the app remembered which ones it had seen.
+29 -13
View File
@@ -49,9 +49,10 @@ class ArticleExtractor {
/// Hard cap on what we send to the model per article (~10k tokens).
static const maxChars = 40000;
/// Loads the article page and returns whichever is longer: the extracted
/// page or the full content some feeds ship. Never throws; on failure
/// (paywall, bot protection, CORS on web) it falls back to feed content.
/// Loads the article page and returns whichever has more text (on a tie,
/// more blocks): the extracted page or the full content some feeds ship.
/// Never throws; on failure (paywall, bot protection, CORS on web) it falls
/// back to feed content.
///
/// With [acceptConsentWalls], a redirect to a consent page the
/// [ConsentService] knows is accepted once and the article fetched again.
@@ -81,7 +82,12 @@ class ArticleExtractor {
if (page.wallUrl != null) return ArticleContent(fromFeed.blocks, consentWall: true);
if (page.status != 200) return fromFeed;
final fromPage = parse(FeedService.decodeBody(page.bytes), base);
return fromPage.plainText.length > fromFeed.plainText.length ? fromPage : fromFeed;
final pageText = fromPage.plainText.length, feedText = fromFeed.plainText.length;
// Without text either way (a comic), the one with the picture wins.
return pageText > feedText ||
(pageText == feedText && fromPage.blocks.length > fromFeed.blocks.length)
? fromPage
: fromFeed;
} catch (_) {
return fromFeed;
}
@@ -119,7 +125,8 @@ class ArticleExtractor {
/// Class/id fragments of page furniture that sits inside article bodies.
static final _junkMarker = RegExp(
r'comment|related|newsletter|share|social|promo|advert|paywall|subscribe|'
r'recommend|author-box|byline|breadcrumb|tags|toolbar|consent|cookie|card|native-ad',
r'recommend|author-box|byline|breadcrumb|tags|toolbar|consent|cookie|card|native-ad|'
r'license',
caseSensitive: false,
);
@@ -155,14 +162,15 @@ class ArticleExtractor {
final blocks = <ReaderBlock>[];
final seenImages = <String>{};
var chars = 0;
void add(BlockType type, String text) {
if (chars > maxChars) return;
bool add(BlockType type, String text) {
if (chars > maxChars) return false;
if (type == BlockType.image) {
if (!seenImages.add(text)) return;
if (!seenImages.add(text)) return false;
} else {
chars += text.length;
}
blocks.add(ReaderBlock(type, text));
return true;
}
void walk(dom.Element el) {
@@ -190,6 +198,8 @@ class ArticleExtractor {
case 'blockquote':
final t = _clean(el.text);
if (t.isNotEmpty) add(BlockType.quote, t);
// Quoted screenshots, as in The Daily WTF's Error'd.
el.querySelectorAll('img').forEach((img) => _addImage(img, base, add));
return;
case 'pre':
final t = el.text.trimRight();
@@ -221,7 +231,7 @@ class ArticleExtractor {
static String _clean(String s) => s.replaceAll(RegExp(r'\s+'), ' ').trim();
static void _addImage(
dom.Element img, Uri base, void Function(BlockType, String) add) {
dom.Element img, Uri base, bool Function(BlockType, String) add) {
// Lazy-loading sites keep the real URL in data attributes.
final raw = img.attributes['data-src'] ??
img.attributes['data-lazy-src'] ??
@@ -232,19 +242,25 @@ class ArticleExtractor {
}
final width = int.tryParse(img.attributes['width'] ?? '');
if (width != null && width < 120) return; // icons, avatars, tracking pixels
if (img.attributes.containsKey('usemap')) return; // clickable navigation
// Images linking to another page are author avatars or related-story
// thumbnails; links to the full-size image file are fine.
// thumbnails; links to the full-size image file or within the page are fine.
for (var p = img.parent; p != null; p = p.parent) {
if (p.localName == 'a') {
final href = p.attributes['href'] ?? '';
if (!RegExp(r'\.(jpe?g|png|webp|gif|avif)(\?|$)', caseSensitive: false)
.hasMatch(href)) {
if (!href.startsWith('#') &&
!RegExp(r'\.(jpe?g|png|webp|gif|avif)(\?|$)', caseSensitive: false).hasMatch(href)) {
return;
}
break;
}
}
final url = base.resolve(raw).toString();
if (url.startsWith('http')) add(BlockType.image, url);
if (!url.startsWith('http') || !add(BlockType.image, url)) return;
// Comics such as xkcd put a last joke in the title text.
final title = _clean(img.attributes['title'] ?? '');
if (title.length >= 20 && title != _clean(img.attributes['alt'] ?? '')) {
add(BlockType.caption, title);
}
}
}
+16 -5
View File
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../models/article.dart';
import '../models/news_source.dart';
import '../services/article_extractor.dart';
import '../services/consent_service.dart';
import '../services/content_blocking.dart';
@@ -275,12 +276,22 @@ class _ReaderView extends StatelessWidget {
builder: (context, snapshot) {
final loaded = snapshot.data;
var blocks = loaded?.blocks ?? const <ReaderBlock>[];
// Comics and memes are the picture: show it whole in the body
// rather than as a cropped banner.
final pictureLed = article.source.kind == SourceKind.fun;
final banner = pictureLed ? null : article.imageUrl;
// The page's own hero image duplicates the feed image shown on top.
if (article.imageUrl != null) {
if (banner != null) {
blocks = blocks.skipWhile((b) => b.type == BlockType.image).toList();
}
if (pictureLed &&
article.imageUrl != null &&
!blocks.any((b) => b.type == BlockType.image)) {
blocks = [ReaderBlock(BlockType.image, article.imageUrl!), ...blocks];
}
final isFull = loaded != null &&
loaded.plainText.length >= max(400, article.excerpt.length * 2);
(loaded.plainText.length >= max(400, article.excerpt.length * 2) ||
(pictureLed && blocks.any((b) => b.type == BlockType.image)));
// Skip the lead if the body already starts with it.
final firstText = blocks.where((b) => b.type == BlockType.paragraph).firstOrNull;
final showLead = article.excerpt.isNotEmpty &&
@@ -291,9 +302,9 @@ class _ReaderView extends StatelessWidget {
return ListView(
padding: const EdgeInsets.only(bottom: 48),
children: [
if (article.imageUrl != null)
if (banner != null)
Image.network(
article.imageUrl!,
banner,
fit: BoxFit.cover,
webHtmlElementStrategy: WebHtmlElementStrategy.prefer,
// Take up space only once the image has loaded, so a broken
@@ -336,7 +347,7 @@ class _ReaderView extends StatelessWidget {
),
if (isFull)
for (final block in blocks)
if (block.text != article.imageUrl) _BlockView(block: block),
if (block.text != banner) _BlockView(block: block),
if (loaded != null) ...[
const SizedBox(height: 24),
const Divider(),
+1 -1
View File
@@ -13,7 +13,7 @@ class LearnScreen extends StatelessWidget {
final AppState state;
/// The sections, top to bottom.
static const kinds = [SourceKind.learning];
static const kinds = [SourceKind.learning, SourceKind.fun];
@override
Widget build(BuildContext context) {
+78
View File
@@ -102,8 +102,86 @@ void main() {
isFragment: true);
expect(content.blocks, hasLength(2));
});
test('an xkcd page gives the comic and its title text, without the page around it', () {
final content = ArticleExtractor.parse(xkcdPage, Uri.parse('https://xkcd.com/3312/'));
expect(content.blocks.map((b) => (b.type, b.text)), [
(BlockType.image, 'https://imgs.xkcd.com/comics/momentum_demonstration.png'),
(BlockType.caption, xkcdTitleText),
]);
});
test("The Daily WTF's Error'd screenshots show between their stories", () {
final content = ArticleExtractor.parse(
errordPage, Uri.parse('https://thedailywtf.com/articles/scrollpocalypse'));
expect(content.blocks.map((b) => b.type), [
BlockType.paragraph,
BlockType.image,
BlockType.paragraph,
BlockType.image,
]);
expect(content.blocks[1].text, 'https://cdn.example.com/errord/first.png');
});
}
const xkcdTitleText = '"I\'m doing really well on the Hertzsprung-Russell academic diagram, '
'which has spectral class on one axis and Rate My Professors rating on the other."';
/// Trimmed from https://xkcd.com/3312/.
const xkcdPage = '''
<!DOCTYPE html>
<html><head><title>xkcd: Momentum Demonstration</title></head>
<body>
<div id="topContainer">
<div id="topLeft"><ul><li><a href="/archive">Archive</a></li><li><a href="/about">About</a></li></ul></div>
<div id="topRight">
<div id="masthead"><span><a href="/"><img src="/s/0b7742.png" alt="xkcd.com logo" height="83" width="185"/></a></span>
<span id="slogan">A webcomic of romance,<br/> sarcasm, math, and language.</span></div>
<div id="news"><div id="xkcdNews"><a href="https://store.xkcd.com/"><img src="https://imgs.xkcd.com/store_news/news_back.png" alt="The xkcd store is back, with shirts, stickers, and posters for sale." title="xkcd store news" /></a></div></div>
</div>
</div>
<div id="middleContainer" class="box">
<div id="ctitle">Momentum Demonstration</div>
<ul class="comicNav"><li><a href="/1/">|&lt;</a></li><li><a rel="prev" href="/3311/">&lt; Prev</a></li></ul>
<div id="comic">
<img src="//imgs.xkcd.com/comics/momentum_demonstration.png" title="&quot;I&#39;m doing really well on the Hertzsprung-Russell academic diagram, which has spectral class on one axis and Rate My Professors rating on the other.&quot;" alt="Momentum Demonstration" srcset="//imgs.xkcd.com/comics/momentum_demonstration_2x.png 2x" style="image-orientation:none" />
</div>
<ul class="comicNav"><li><a href="/1/">|&lt;</a></li><li><a rel="prev" href="/3311/">&lt; Prev</a></li></ul>
<br />
Permanent link to this comic: <a href="https://xkcd.com/3312">https://xkcd.com/3312/</a><br />
Image URL (for hotlinking/embedding): <a href= "https://imgs.xkcd.com/comics/momentum_demonstration.png">https://imgs.xkcd.com/comics/momentum_demonstration.png</a>
<div id="transcript" style="display: none"></div>
</div>
<div id="bottom" class="box">
<img src="//imgs.xkcd.com/s/a899e84.jpg" width="520" height="100" alt="Selected Comics" usemap="#comicmap"/>
<map id="comicmap" name="comicmap"><area shape="rect" coords="0,0,100,100" href="/150/" alt="Grownups"/></map>
<div id="comicLinks">Comics I enjoy:<br/><a href="http://threewordphrase.com/">Three Word Phrase</a></div>
<div id="footnote" style="width:70%">xkcd.com is best viewed with Netscape Navigator 4.0 or below on a Pentium 3&plusmn;1 emulated in Javascript on an Apple IIGS</div>
<div id="licenseText">
<p>
This work is licensed under a
<a href="https://creativecommons.org/licenses/by-nc/2.5/">Creative Commons Attribution-NonCommercial 2.5 License</a>.
</p><p>
This means you're free to copy and share these comics (but not to sell them). <a rel="license" href="/license.html">More details</a>.</p>
</div>
</div>
</body></html>''';
/// Trimmed from https://thedailywtf.com/articles/scrollpocalypse.
const errordPage = '''
<html><body>
<div itemscope itemtype="http://schema.org/Article" class="article-container errord">
<div itemprop="articleBody" class="article-body">
<p>Dr. Doom...scroller titled this one "Scrollbar-ception". Said he "Screenshot of the TikTok webpage."</p>
<blockquote><p><a href="#d6711f9e"><img itemprop="image" border="0" alt="d6711f9e" src="https://cdn.example.com/errord/first.png"/></a></p><p> </p></blockquote>
<p>An anonymous reader clarified "I was reorganizing the document by drag-and-dropping table cells."</p>
<blockquote><p><a href="#01270f37"><img itemprop="image" border="0" alt="01270f37" src="https://cdn.example.com/errord/second.png"/></a></p><p> </p></blockquote>
</div>
</div>
</body></html>''';
/// Answers every request as if the server had redirected to [finalUrl].
class _RedirectingClient extends http.BaseClient {
_RedirectingClient(this.finalUrl, this.body);
+109
View File
@@ -0,0 +1,109 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:daily_tech_news/models/article.dart';
import 'package:daily_tech_news/models/news_source.dart';
import 'package:daily_tech_news/services/article_extractor.dart';
import 'package:daily_tech_news/ui/article_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'support/fake_app.dart';
/// A plain PNG of the given size, standing in for a comic strip or meme.
Future<List<int>> _png(int width, int height) async {
final recorder = ui.PictureRecorder();
Canvas(recorder).drawRect(
Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), Paint()..color = Colors.black);
final image = await recorder.endRecording().toImage(width, height);
return (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer.asUint8List();
}
/// Serves a [width] x [height] PNG from this machine, so the reader view can
/// load and lay out a real picture. Returns its URL.
Future<String> _serveImage(WidgetTester tester, int width, int height) async {
HttpOverrides.global = null;
final server = (await tester.runAsync(() => HttpServer.bind(InternetAddress.loopbackIPv4, 0)))!;
addTearDown(() => tester.runAsync(server.close));
final png = (await tester.runAsync(() => _png(width, height)))!;
server.listen((request) => request.response
// No idle keep-alive timer left behind in the test's fake time.
..persistentConnection = false
..headers.contentType = ContentType('image', 'png')
..add(png)
..close());
return 'http://127.0.0.1:${server.port}/picture.png';
}
/// Opens [article] in the reader, with the article page answered by [page],
/// and waits until the picture at [imageUrl] has loaded. Returns its finder.
Future<Finder> _openAndLoad(
WidgetTester tester, Article article, String imageUrl, http.Response page) async {
final state = (await tester.runAsync(() => launchApp([article.source],
extractor: ArticleExtractor(client: MockClient((_) async => page)))))!;
await tester.pumpWidget(MaterialApp(home: ArticleScreen(state: state, article: article)));
final picture = find.descendant(
of: find.byWidgetPredicate((w) =>
w is Image && w.image is NetworkImage && (w.image as NetworkImage).url == imageUrl),
matching: find.byType(RawImage));
for (var i = 0; i < 100; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
await tester.pump();
if (picture.evaluate().isNotEmpty && tester.widget<RawImage>(picture).image != null) break;
}
return picture;
}
double _aspect(WidgetTester tester, Finder picture) {
final size = tester.getSize(picture);
return size.height / size.width;
}
const _titleText = 'The title text is the last joke, hidden in the mouse-over.';
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
testWidgets('opening an xkcd comic shows the whole comic and its title text', (tester) async {
final comicUrl = await _serveImage(tester, 300, 100);
final page = '''
<html><body>
<div id="ctitle">Strip</div>
<div id="comic"><img src="$comicUrl" title="$_titleText" alt="Strip"></div>
<div id="licenseText"><p>This work is licensed under a Creative Commons Attribution-NonCommercial 2.5 License.</p></div>
</body></html>''';
// What the xkcd feed gives: a title, a link and the comic as the image.
final article = Article(
title: 'Strip',
link: 'https://xkcd.com/9999/',
source: testSource('xkcd', SourceKind.fun),
imageUrl: comicUrl);
final comic = await _openAndLoad(tester, article, comicUrl,
http.Response(page, 200, headers: {'content-type': 'text/html; charset=utf-8'}));
expect(tester.widget<RawImage>(comic).image, isNotNull, reason: 'the comic loads');
expect(_aspect(tester, comic), closeTo(100 / 300, 0.01), reason: 'the comic is not cropped');
expect(find.text(_titleText), findsOneWidget);
expect(find.textContaining("couldn't be loaded"), findsNothing);
});
testWidgets('a meme whose page blocks the reader still shows the picture from the feed',
(tester) async {
final memeUrl = await _serveImage(tester, 100, 200);
final article = Article(
title: 'vibeCoding',
link: 'https://www.reddit.com/r/ProgrammerHumor/comments/1/vibecoding/',
source: testSource('programmerhumor', SourceKind.fun),
imageUrl: memeUrl);
final meme = await _openAndLoad(tester, article, memeUrl, http.Response('Blocked', 403));
expect(tester.widget<RawImage>(meme).image, isNotNull, reason: 'the meme loads');
expect(_aspect(tester, meme), closeTo(200 / 100, 0.01), reason: 'the meme is not cropped');
expect(find.textContaining("couldn't be loaded"), findsNothing);
});
}
+102
View File
@@ -0,0 +1,102 @@
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:daily_tech_news/state/app_state.dart';
import 'package:daily_tech_news/ui/feed_screen.dart';
import 'package:daily_tech_news/ui/learn_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';
/// 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);
}
}
void main() {
final verge = testSource('verge', SourceKind.news);
final bytebytego = testSource('bytebytego', SourceKind.learning);
final xkcd = testSource('xkcd', SourceKind.fun);
final feeds = {
verge.feedUrl: [('News from this morning', const Duration(hours: 2))],
bytebytego.feedUrl: [('Explainer from 2 days ago', const Duration(days: 2))],
xkcd.feedUrl: [('Comic from today', const Duration(hours: 5)),
('Comic from 2 days ago', const Duration(days: 2)),
('Comic from 4 days ago', const Duration(days: 4))],
};
setUp(() => SharedPreferences.setMockInitialValues({}));
Future<AppState> launch(WidgetTester tester, {Summarizer? summarizer}) async =>
(await tester.runAsync(() => launchApp([verge, bytebytego, xkcd],
feeds: feeds, summarizer: summarizer)))!;
double top(WidgetTester tester, String text) => tester.getTopLeft(find.text(text)).dy;
test('the curated Fun sources are xkcd and The Daily WTF on, r/ProgrammerHumor off', () {
final fun = {
for (final s in defaultSources)
if (s.kind == SourceKind.fun) s.name: (s.feedUrl, s.language, s.enabledByDefault),
};
expect(fun, {
'xkcd': ('https://xkcd.com/atom.xml', SourceLanguage.en, true),
'The Daily WTF': ('https://thedailywtf.com/rss', SourceLanguage.en, true),
'r/ProgrammerHumor': (
'https://www.reddit.com/r/ProgrammerHumor/top/.rss?t=day',
SourceLanguage.en,
false,
),
});
});
testWidgets('the Learn tab has a Fun section below Learning with the last 3 days of Fun',
(tester) async {
final state = await launch(tester);
await tester.pumpWidget(MaterialApp(home: LearnScreen(state: state)));
expect(find.text('Fun'), findsOneWidget);
expect(find.text('2 articles · last 3 days'), findsOneWidget);
expect(top(tester, 'Learning'), lessThan(top(tester, 'Fun')));
expect(top(tester, 'Explainer from 2 days ago'), lessThan(top(tester, 'Fun')));
expect(top(tester, 'Fun'), lessThan(top(tester, 'Comic from today')));
expect(top(tester, 'Comic from today'), lessThan(top(tester, 'Comic from 2 days ago')));
expect(find.text('Comic from 4 days ago'), findsNothing);
});
testWidgets('Today leaves out Fun 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('Comic from today'), findsNothing);
});
testWidgets('the Briefing leaves out Fun articles', (tester) async {
final summarizer = _RecordingSummarizer();
final state = await launch(tester, summarizer: summarizer);
await tester.runAsync(state.generateBriefing);
expect(summarizer.briefingInput!.map((a) => a.title), ['News from this morning']);
});
}
+2
View File
@@ -88,6 +88,7 @@ void main() {
'heise', 'verge', 'mit-tr', 'new-source',
// Added to the catalog later, on by default.
'techmeme', 'lobsters', 'the-decoder', 'simonwillison', 'bytebytego',
'xkcd', 'thedailywtf',
});
});
@@ -103,6 +104,7 @@ void main() {
expect(state.enabledSourceIds, {
'heise', 'golem', 't3n', 'verge',
'techmeme', 'lobsters', 'the-decoder', 'simonwillison', 'bytebytego',
'xkcd', 'thedailywtf',
});
expect(state.enabledSources.map((s) => s.id), isNot(contains('wired')));
});
+4 -1
View File
@@ -1,5 +1,6 @@
import 'package:daily_tech_news/models/article.dart';
import 'package:daily_tech_news/models/news_source.dart';
import 'package:daily_tech_news/services/article_extractor.dart';
import 'package:daily_tech_news/services/feed_service.dart';
import 'package:daily_tech_news/services/storage.dart';
import 'package:daily_tech_news/services/summarizer.dart';
@@ -58,11 +59,13 @@ Future<AppState> launchApp(
Map<String, List<(String, Duration)>> feeds = const {},
FeedService? feedService,
Summarizer? summarizer,
ArticleExtractor? extractor,
}) async {
final state = AppState(await Storage.open(),
feedService: feedService ?? FakeFeeds(feeds),
curatedSources: curatedSources,
summarizer: summarizer);
summarizer: summarizer,
extractor: extractor);
await state.init();
return state;
}