Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1048e4e384 | ||
|
|
8240a767ef | ||
|
|
4f035e4bb4 | ||
|
|
b634118ed8 |
No files matched your search
@@ -8,6 +8,9 @@ or write a daily briefing. It's meant to replace the n8n → Discord workflow.
|
||||
|
||||
- **Today**: all articles from the enabled sources in the last 12/24/48 h, newest
|
||||
first. Filter by language or source, pull to refresh.
|
||||
- **Learn**: articles from the enabled Learning sources (engineering blogs,
|
||||
system design explainers, AI research write-ups) from the last 14 days, newest
|
||||
first. Learning sources aren't part of the Briefing.
|
||||
- **Reading in the app**: tapping an article opens a clean **reader view** (text,
|
||||
headings, lists, quotes, images) without ads or cookie banners. The globe icon
|
||||
switches to the **original page** in an in-app WebView, for paywalls or sites
|
||||
@@ -181,6 +184,51 @@ 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.
|
||||
|
||||
## Learning sources
|
||||
|
||||
Evergreen reading for building skills: research, engineering depth and system
|
||||
design rather than product news. The Learn tab shows their posts from the last
|
||||
14 days. All are English; ★ marks the ones enabled by default.
|
||||
|
||||
| | Source | Feed | Focus |
|
||||
|---|---|---|---|
|
||||
| 🇬🇧 ★ | ByteByteGo | `blog.bytebytego.com/feed` | System design explained with diagrams |
|
||||
| 🇬🇧 ★ | System Design One | `newsletter.systemdesign.one/feed` | System design case studies of real systems |
|
||||
| 🇬🇧 ★ | The Pragmatic Engineer (blog) | `blog.pragmaticengineer.com/rss/` | How big tech and startups build software (free posts) |
|
||||
| 🇬🇧 ★ | Martin Fowler | `martinfowler.com/feed.atom` | Software architecture, refactoring, delivery |
|
||||
| 🇬🇧 ★ | Julia Evans | `jvns.ca/atom.xml` | Systems, networking and Unix tools, explained plainly |
|
||||
| 🇬🇧 ★ | Dan Luu | `danluu.com/atom.xml` | Long, data-driven essays on computers and engineering |
|
||||
| 🇬🇧 ★ | The Cloudflare Blog | `blog.cloudflare.com/rss/` | Networking, security, incident write-ups |
|
||||
| 🇬🇧 ★ | Netflix Technology Blog | `netflixtechblog.com/feed` | Distributed systems, data, streaming at scale |
|
||||
| 🇬🇧 ★ | Engineering at Meta | `engineering.fb.com/feed/` | Infrastructure, AI, production engineering |
|
||||
| 🇬🇧 ★ | GitHub Blog: Engineering | `github.blog/engineering/feed/` | How GitHub builds and runs GitHub |
|
||||
| 🇬🇧 ★ | Latent Space | `www.latent.space/feed` | AI engineering: interviews and deep dives |
|
||||
| 🇬🇧 ★ | Import AI | `importai.substack.com/feed` | Weekly AI research digest by Jack Clark |
|
||||
| 🇬🇧 ★ | Interconnects | `www.interconnects.ai/feed` | Language model training and research |
|
||||
| 🇬🇧 ★ | Ahead of AI | `magazine.sebastianraschka.com/feed` | LLM research explained by Sebastian Raschka |
|
||||
| 🇬🇧 ★ | Lilian Weng | `lilianweng.github.io/index.xml` | Thorough surveys of machine learning topics |
|
||||
| 🇬🇧 | InfoQ | `feed.infoq.com/` | Architecture and engineering talks, high volume |
|
||||
|
||||
Left out on purpose: The Pragmatic Engineer newsletter (mostly paid), One Useful
|
||||
Thing (applied AI), vendor blogs (OpenAI, DeepMind, Hugging Face) and Chip Huyen
|
||||
(inactive since January 2025). Some feeds are long (Dan Luu ~130 posts, Lilian
|
||||
Weng ~50); the 14-day window keeps the list short.
|
||||
|
||||
## 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
|
||||
|
||||
```
|
||||
|
||||
@@ -212,6 +212,186 @@ const defaultSources = <NewsSource>[
|
||||
description: 'System design explained with diagrams',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'systemdesignone',
|
||||
name: 'System Design One',
|
||||
feedUrl: 'https://newsletter.systemdesign.one/feed',
|
||||
homepage: 'https://newsletter.systemdesign.one',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.learning,
|
||||
description: 'System design case studies of real systems',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'pragmaticengineer-blog',
|
||||
name: 'The Pragmatic Engineer (blog)',
|
||||
feedUrl: 'https://blog.pragmaticengineer.com/rss/',
|
||||
homepage: 'https://blog.pragmaticengineer.com',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.learning,
|
||||
description: 'How big tech and startups build software (free posts)',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'martinfowler',
|
||||
name: 'Martin Fowler',
|
||||
feedUrl: 'https://martinfowler.com/feed.atom',
|
||||
homepage: 'https://martinfowler.com',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.learning,
|
||||
description: 'Software architecture, refactoring, delivery practices',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'juliaevans',
|
||||
name: 'Julia Evans',
|
||||
feedUrl: 'https://jvns.ca/atom.xml',
|
||||
homepage: 'https://jvns.ca',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.learning,
|
||||
description: 'Systems, networking and Unix tools, explained plainly',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'danluu',
|
||||
name: 'Dan Luu',
|
||||
feedUrl: 'https://danluu.com/atom.xml',
|
||||
homepage: 'https://danluu.com',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.learning,
|
||||
description: 'Long, data-driven essays on computers and engineering',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'cloudflare-blog',
|
||||
name: 'The Cloudflare Blog',
|
||||
feedUrl: 'https://blog.cloudflare.com/rss/',
|
||||
homepage: 'https://blog.cloudflare.com',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.learning,
|
||||
description: 'Networking, security and incident write-ups in depth',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'netflix-techblog',
|
||||
name: 'Netflix Technology Blog',
|
||||
feedUrl: 'https://netflixtechblog.com/feed',
|
||||
homepage: 'https://netflixtechblog.com',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.learning,
|
||||
description: 'Distributed systems, data and streaming at scale',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'meta-engineering',
|
||||
name: 'Engineering at Meta',
|
||||
feedUrl: 'https://engineering.fb.com/feed/',
|
||||
homepage: 'https://engineering.fb.com',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.learning,
|
||||
description: 'Infrastructure, AI and production engineering at Meta',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'github-engineering',
|
||||
name: 'GitHub Blog: Engineering',
|
||||
feedUrl: 'https://github.blog/engineering/feed/',
|
||||
homepage: 'https://github.blog/engineering/',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.learning,
|
||||
description: 'How GitHub builds and runs GitHub',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'latent-space',
|
||||
name: 'Latent Space',
|
||||
feedUrl: 'https://www.latent.space/feed',
|
||||
homepage: 'https://www.latent.space',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.learning,
|
||||
description: 'AI engineering: interviews and deep dives',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'import-ai',
|
||||
name: 'Import AI',
|
||||
feedUrl: 'https://importai.substack.com/feed',
|
||||
homepage: 'https://importai.substack.com',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.learning,
|
||||
description: 'Weekly AI research digest by Jack Clark',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'interconnects',
|
||||
name: 'Interconnects',
|
||||
feedUrl: 'https://www.interconnects.ai/feed',
|
||||
homepage: 'https://www.interconnects.ai',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.learning,
|
||||
description: 'Language model training and research, analysed',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'ahead-of-ai',
|
||||
name: 'Ahead of AI',
|
||||
feedUrl: 'https://magazine.sebastianraschka.com/feed',
|
||||
homepage: 'https://magazine.sebastianraschka.com',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.learning,
|
||||
description: 'LLM research explained, by Sebastian Raschka',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'lilianweng',
|
||||
name: 'Lilian Weng',
|
||||
feedUrl: 'https://lilianweng.github.io/index.xml',
|
||||
homepage: 'https://lilianweng.github.io',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.learning,
|
||||
description: 'Thorough surveys of machine learning topics',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'infoq',
|
||||
name: 'InfoQ',
|
||||
feedUrl: 'https://feed.infoq.com/',
|
||||
homepage: 'https://www.infoq.com',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.learning,
|
||||
description: 'Software architecture and engineering talks, high volume',
|
||||
),
|
||||
|
||||
// --- 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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/">|<</a></li><li><a rel="prev" href="/3311/">< Prev</a></li></ul>
|
||||
<div id="comic">
|
||||
<img src="//imgs.xkcd.com/comics/momentum_demonstration.png" title=""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."" 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/">|<</a></li><li><a rel="prev" href="/3311/">< 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±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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -43,6 +43,40 @@ void main() {
|
||||
expect(ids, isNot(contains('engadget')));
|
||||
});
|
||||
|
||||
test('the Learning sources are the curated set, all English, with the agreed defaults', () {
|
||||
final learning = {
|
||||
for (final s in defaultSources)
|
||||
if (s.kind == SourceKind.learning) s.name: (s.feedUrl, s.language, s.enabledByDefault),
|
||||
};
|
||||
|
||||
const en = SourceLanguage.en;
|
||||
expect(learning, {
|
||||
'ByteByteGo': ('https://blog.bytebytego.com/feed', en, true),
|
||||
'System Design One': ('https://newsletter.systemdesign.one/feed', en, true),
|
||||
'The Pragmatic Engineer (blog)': ('https://blog.pragmaticengineer.com/rss/', en, true),
|
||||
'Martin Fowler': ('https://martinfowler.com/feed.atom', en, true),
|
||||
'Julia Evans': ('https://jvns.ca/atom.xml', en, true),
|
||||
'Dan Luu': ('https://danluu.com/atom.xml', en, true),
|
||||
'The Cloudflare Blog': ('https://blog.cloudflare.com/rss/', en, true),
|
||||
'Netflix Technology Blog': ('https://netflixtechblog.com/feed', en, true),
|
||||
'Engineering at Meta': ('https://engineering.fb.com/feed/', en, true),
|
||||
'GitHub Blog: Engineering': ('https://github.blog/engineering/feed/', en, true),
|
||||
'Latent Space': ('https://www.latent.space/feed', en, true),
|
||||
'Import AI': ('https://importai.substack.com/feed', en, true),
|
||||
'Interconnects': ('https://www.interconnects.ai/feed', en, true),
|
||||
'Ahead of AI': ('https://magazine.sebastianraschka.com/feed', en, true),
|
||||
'Lilian Weng': ('https://lilianweng.github.io/index.xml', en, true),
|
||||
'InfoQ': ('https://feed.infoq.com/', en, false),
|
||||
});
|
||||
});
|
||||
|
||||
test('every Learning source says what it is for and links its homepage', () {
|
||||
for (final s in defaultSources.where((s) => s.kind == SourceKind.learning)) {
|
||||
expect(s.description, isNotEmpty, reason: s.name);
|
||||
expect(Uri.parse(s.homepage).isScheme('https'), isTrue, reason: s.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('every Source has its own id', () {
|
||||
final ids = defaultSources.map((s) => s.id).toList();
|
||||
|
||||
|
||||
@@ -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,10 +2,14 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:daily_tech_news/data/default_sources.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/consent_service.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';
|
||||
|
||||
void main() {
|
||||
final live = Platform.environment['LIVE_FEEDS'] == '1';
|
||||
@@ -29,6 +33,19 @@ void main() {
|
||||
expect(failures, isEmpty);
|
||||
}, skip: live ? false : 'set LIVE_FEEDS=1 to run', timeout: const Timeout(Duration(minutes: 3)));
|
||||
|
||||
test('a full refresh with all on-by-default Sources stays fast', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final state = AppState(await Storage.open(), feedService: FeedService());
|
||||
final clock = Stopwatch()..start();
|
||||
await state.init();
|
||||
clock.stop();
|
||||
// ignore: avoid_print
|
||||
print('${state.enabledSources.length} Sources, ${state.articles.length} articles '
|
||||
'(${state.articlesOf(SourceKind.learning).length} Learning) in '
|
||||
'${clock.elapsedMilliseconds} ms; errors: ${state.feedErrors}');
|
||||
expect(clock.elapsed, lessThan(const Duration(seconds: 15)));
|
||||
}, skip: live ? false : 'set LIVE_FEEDS=1 to run', timeout: const Timeout(Duration(minutes: 1)));
|
||||
|
||||
test('reader view extracts the article body', () async {
|
||||
final service = FeedService();
|
||||
final extractor = ArticleExtractor(consent: ConsentService());
|
||||
|
||||
@@ -135,4 +135,24 @@ void main() {
|
||||
expect(bytebytego.language, SourceLanguage.en);
|
||||
expect(bytebytego.enabledByDefault, isTrue);
|
||||
});
|
||||
|
||||
test('a fresh install shows posts of the last 14 days from the curated Learning sources',
|
||||
() async {
|
||||
final learning = defaultSources.where((s) => s.kind == SourceKind.learning);
|
||||
final state = await launchApp(defaultSources, feeds: {
|
||||
for (final s in learning)
|
||||
s.feedUrl: [('${s.name}: recent', const Duration(days: 13)),
|
||||
('${s.name}: old', const Duration(days: 15))],
|
||||
});
|
||||
|
||||
expect(_titles(state.articlesOf(SourceKind.learning)).toSet(), {
|
||||
'ByteByteGo: recent', 'System Design One: recent',
|
||||
'The Pragmatic Engineer (blog): recent', 'Martin Fowler: recent', 'Julia Evans: recent',
|
||||
'Dan Luu: recent', 'The Cloudflare Blog: recent', 'Netflix Technology Blog: recent',
|
||||
'Engineering at Meta: recent', 'GitHub Blog: Engineering: recent', 'Latent Space: recent',
|
||||
'Import AI: recent', 'Interconnects: recent', 'Ahead of AI: recent',
|
||||
'Lilian Weng: recent',
|
||||
// InfoQ is off by default.
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -23,6 +23,13 @@ NewsSource _source(String id, {bool onByDefault = false}) => NewsSource(
|
||||
enabledByDefault: onByDefault,
|
||||
);
|
||||
|
||||
/// The curated Learning sources that are on by default (all but InfoQ).
|
||||
const _learningOnByDefault = {
|
||||
'bytebytego', 'systemdesignone', 'pragmaticengineer-blog', 'martinfowler', 'juliaevans',
|
||||
'danluu', 'cloudflare-blog', 'netflix-techblog', 'meta-engineering', 'github-engineering',
|
||||
'latent-space', 'import-ai', 'interconnects', 'ahead-of-ai', 'lilianweng',
|
||||
};
|
||||
|
||||
/// Starts the app the way a launch does, against whatever is in preferences.
|
||||
Future<AppState> _launch(List<NewsSource> curatedSources) async {
|
||||
final state =
|
||||
@@ -87,7 +94,8 @@ void main() {
|
||||
expect(state.enabledSourceIds, {
|
||||
'heise', 'verge', 'mit-tr', 'new-source',
|
||||
// Added to the catalog later, on by default.
|
||||
'techmeme', 'lobsters', 'the-decoder', 'simonwillison', 'bytebytego',
|
||||
'techmeme', 'lobsters', 'the-decoder', 'simonwillison', ..._learningOnByDefault,
|
||||
'xkcd', 'thedailywtf',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,8 +110,10 @@ void main() {
|
||||
|
||||
expect(state.enabledSourceIds, {
|
||||
'heise', 'golem', 't3n', 'verge',
|
||||
'techmeme', 'lobsters', 'the-decoder', 'simonwillison', 'bytebytego',
|
||||
'techmeme', 'lobsters', 'the-decoder', 'simonwillison', ..._learningOnByDefault,
|
||||
'xkcd', 'thedailywtf',
|
||||
});
|
||||
expect(state.enabledSources.map((s) => s.id), isNot(contains('wired')));
|
||||
expect(state.enabledSourceIds, isNot(contains('infoq')));
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/state/app_state.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/sources_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -150,4 +151,34 @@ void main() {
|
||||
expect(find.text('News from this morning'), findsOneWidget);
|
||||
expect(find.text('Explainer from my blog'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a custom Fun source shows up in the Fun section of the Learn tab, not in Today',
|
||||
(tester) async {
|
||||
final feeds = FakeFeeds({
|
||||
verge.feedUrl: [('News from this morning', const Duration(hours: 2))],
|
||||
'https://example.com/mycomic.xml': [('Comic from my feed', const Duration(days: 1))],
|
||||
});
|
||||
final state = (await tester.runAsync(() => launchApp([verge], feedService: feeds)))!;
|
||||
tester.view.physicalSize = const Size(800, 2400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(MaterialApp(home: HomeShell(state: state)));
|
||||
Finder tab(String label) =>
|
||||
find.descendant(of: find.byType(NavigationBar), matching: find.text(label));
|
||||
Finder onLearn(String text) =>
|
||||
find.descendant(of: find.byType(LearnScreen), matching: find.text(text));
|
||||
|
||||
await tester.tap(tab('Sources'));
|
||||
await tester.pump();
|
||||
await addFeed(tester, 'mycomic', kind: 'Fun');
|
||||
await tester.tap(tab('Learn'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expectInOrder(tester, [onLearn('Learning'), onLearn('Fun'), onLearn('Comic from my feed')]);
|
||||
|
||||
await tester.tap(tab('Today'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('News from this morning'), findsOneWidget);
|
||||
expect(find.text('Comic from my feed'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user