Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84cb11e4dd | ||
|
|
4f035e4bb4 | ||
|
|
f205452b25 |
No files matched your search
@@ -25,7 +25,9 @@ or write a daily briefing. It's meant to replace the n8n → Discord workflow.
|
||||
- **Paid articles and ads hidden**: heise+ and Golem `(g+)` are recognized by
|
||||
their title; SPIEGEL+ and t3n Pro by a marker in the article page, which the app
|
||||
checks once per new article in the background (it reads only until the marker,
|
||||
and remembers results for 14 days). Sponsored posts (`Anzeige:`) are hidden too.
|
||||
and remembers results for 14 days). Paid-subscriber posts from Substack newsletters
|
||||
(Interconnects, Ahead of AI, …) are recognized in the feed itself, where Substack
|
||||
cuts them off with a "Read more" link. Sponsored posts (`Anzeige:`) are hidden too.
|
||||
The feed header shows how many were hidden; both filters can be turned off.
|
||||
- **Ad and cookie-banner blocking** in the original-page view: requests to ~80
|
||||
ad/tracker hosts and the common consent managers (Sourcepoint, OneTrust,
|
||||
@@ -37,7 +39,8 @@ or write a daily briefing. It's meant to replace the n8n → Discord workflow.
|
||||
valid for a day) and reuses the cookie for reader view and the in-app page.
|
||||
With it off, the app shows the teaser and an "Open website anyway" option.
|
||||
- **Sources**: 23 curated feeds (13 German, 10 English), toggle each one, or add any
|
||||
RSS/Atom feed (it's validated before being added).
|
||||
RSS/Atom feed as a News, Learning or Fun source (it's validated before being
|
||||
added). The tab groups Sources by kind, then by language.
|
||||
- **Dark mode**: System / Light / Dark in Settings (applies instantly). The
|
||||
original-page view follows it too: when the phone is dark, sites use their own
|
||||
dark design or Android darkens them; when only the app is dark, the app inverts
|
||||
|
||||
@@ -9,6 +9,8 @@ import 'storage.dart';
|
||||
/// Recognizes paid and sponsored articles so the feed can hide them.
|
||||
///
|
||||
/// Most paid articles are marked in the title (heise+, Golem's "(g+)").
|
||||
/// Substack newsletters cut paid posts short in the feed and end them with a
|
||||
/// "Read more" link to the post itself, so those are recognized from the feed.
|
||||
/// SPIEGEL and t3n only mark them in the page's schema.org data
|
||||
/// (`"isAccessibleForFree": false`), so for those sources each new article is
|
||||
/// checked once in the background and the result is remembered.
|
||||
@@ -33,6 +35,14 @@ class PaywallService {
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// What Substack appends to a paid post's feed content in place of the rest:
|
||||
/// `<p><a href="{post url}">Read more</a></p>`, as the very end. Free posts
|
||||
/// end with their own text, even when they ask readers to subscribe.
|
||||
static final _substackCut = RegExp(
|
||||
r'<p>\s*<a href="([^"]+)">\s*Read more\s*</a>\s*</p>\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
static final _marker =
|
||||
RegExp(r'''"isAccessibleForFree"\s*:\s*"?(true|false)"?''', caseSensitive: false);
|
||||
|
||||
@@ -41,7 +51,11 @@ class PaywallService {
|
||||
static const _concurrency = 6;
|
||||
static const _keepFor = Duration(days: 14);
|
||||
|
||||
bool isPaid(Article a) => paidTitle.hasMatch(a.title) || (_checks[a.link]?.paid ?? false);
|
||||
bool isPaid(Article a) =>
|
||||
paidTitle.hasMatch(a.title) || _isCutSubstackPost(a) || (_checks[a.link]?.paid ?? false);
|
||||
|
||||
static bool _isCutSubstackPost(Article a) =>
|
||||
_substackCut.firstMatch(a.contentHtml)?.group(1) == a.link;
|
||||
|
||||
bool isSponsored(Article a) => sponsoredTitle.hasMatch(a.title);
|
||||
|
||||
|
||||
+50
-24
@@ -40,29 +40,7 @@ class SourcesScreen extends StatelessWidget {
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 96),
|
||||
children: [
|
||||
for (final lang in SourceLanguage.values) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text('${lang.flag} ${lang.label}',
|
||||
style: theme.textTheme.titleSmall
|
||||
?.copyWith(color: theme.colorScheme.primary)),
|
||||
),
|
||||
for (final source in state.allSources.where((s) => s.language == lang))
|
||||
_SourceTile(
|
||||
source: source,
|
||||
enabled: state.enabledSourceIds.contains(source.id),
|
||||
onToggle: (v) {
|
||||
state.setSourceEnabled(source, v);
|
||||
onChanged();
|
||||
},
|
||||
onDelete: source.isCustom
|
||||
? () {
|
||||
state.removeCustomSource(source);
|
||||
onChanged();
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
for (final kind in SourceKind.values) ..._kindSection(context, kind),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -70,6 +48,44 @@ class SourcesScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// One section per kind, split by language. Kinds and languages without
|
||||
/// Sources are left out instead of showing an empty header.
|
||||
List<Widget> _kindSection(BuildContext context, SourceKind kind) {
|
||||
final theme = Theme.of(context);
|
||||
final sources = state.allSources.where((s) => s.kind == kind).toList();
|
||||
if (sources.isEmpty) return const [];
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 0),
|
||||
child: Text(kind.label, style: theme.textTheme.titleMedium),
|
||||
),
|
||||
for (final lang in SourceLanguage.values)
|
||||
if (sources.any((s) => s.language == lang)) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text('${lang.flag} ${lang.label}',
|
||||
style: theme.textTheme.titleSmall
|
||||
?.copyWith(color: theme.colorScheme.primary)),
|
||||
),
|
||||
for (final source in sources.where((s) => s.language == lang))
|
||||
_SourceTile(
|
||||
source: source,
|
||||
enabled: state.enabledSourceIds.contains(source.id),
|
||||
onToggle: (v) {
|
||||
state.setSourceEnabled(source, v);
|
||||
onChanged();
|
||||
},
|
||||
onDelete: source.isCustom
|
||||
? () {
|
||||
state.removeCustomSource(source);
|
||||
onChanged();
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _addFeed(BuildContext context) async {
|
||||
final added = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -134,6 +150,7 @@ class _AddFeedDialogState extends State<_AddFeedDialog> {
|
||||
final _name = TextEditingController();
|
||||
final _url = TextEditingController();
|
||||
SourceLanguage _language = SourceLanguage.de;
|
||||
SourceKind _kind = SourceKind.news;
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
@@ -156,7 +173,7 @@ class _AddFeedDialogState extends State<_AddFeedDialog> {
|
||||
_error = null;
|
||||
});
|
||||
final error = await widget.state
|
||||
.addCustomSource(name: name, feedUrl: url, language: _language);
|
||||
.addCustomSource(name: name, feedUrl: url, language: _language, kind: _kind);
|
||||
if (!mounted) return;
|
||||
if (error == null) {
|
||||
Navigator.pop(context, true);
|
||||
@@ -199,6 +216,15 @@ class _AddFeedDialogState extends State<_AddFeedDialog> {
|
||||
selected: {_language},
|
||||
onSelectionChanged: (s) => setState(() => _language = s.first),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SegmentedButton<SourceKind>(
|
||||
segments: [
|
||||
for (final k in SourceKind.values)
|
||||
ButtonSegment(value: k, label: Text(k.label)),
|
||||
],
|
||||
selected: {_kind},
|
||||
onSelectionChanged: (s) => setState(() => _kind = s.first),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Ahead of AI]]></title><link>https://magazine.sebastianraschka.com</link><generator>Substack</generator>
|
||||
<!-- Real items from https://magazine.sebastianraschka.com/feed (October 2026), content:encoded trimmed to its first paragraph and its end. -->
|
||||
<!-- Paid post (audience "only_paid" on its page): Substack cuts the content and ends it with a "Read more" link to the post. -->
|
||||
<item><title><![CDATA[Building an AI Text Detector From Scratch]]></title><description><![CDATA[An End-to-End Project With Dataset Construction, Model Training, Local Deployment, and RLVR]]></description><link>https://magazine.sebastianraschka.com/p/ai-detector-from-scratch</link><guid isPermaLink="false">https://magazine.sebastianraschka.com/p/ai-detector-from-scratch</guid><dc:creator><![CDATA[Sebastian Raschka, PhD]]></dc:creator><pubDate>Sat, 15 Aug 2026 11:54:24 GMT</pubDate><content:encoded><![CDATA[<p>Substack recently launched its AI detector feature in the UI, which is super interesting.</p><p>For this, we are going to fine-tune a DistilBERT classifier (similar to what I described in one of my early Substack articles, <a href="https://magazine.sebastianraschka.com/p/finetuning-large-language-models">Finetuning Large Language Models</a>), but more details on that later when we get to that stage.</p><p></p>
|
||||
<p>
|
||||
<a href="https://magazine.sebastianraschka.com/p/ai-detector-from-scratch">
|
||||
Read more
|
||||
</a>
|
||||
</p>
|
||||
]]></content:encoded></item>
|
||||
<!-- Free post (audience "everyone") whose footer asks readers to become paid subscribers. -->
|
||||
<item><title><![CDATA[Controlling Reasoning Effort in LLMs]]></title><description><![CDATA[How LLMs Learn Low-, Medium-, and High-Effort Reasoning Modes]]></description><link>https://magazine.sebastianraschka.com/p/controlling-reasoning-effort-in-llms</link><guid isPermaLink="false">https://magazine.sebastianraschka.com/p/controlling-reasoning-effort-in-llms</guid><dc:creator><![CDATA[Sebastian Raschka, PhD]]></dc:creator><pubDate>Sat, 18 Jul 2026 11:16:09 GMT</pubDate><content:encoded><![CDATA[<p><span>It has been almost two years since OpenAI released o1, a model that popularized the idea of LLM-based reasoning models. DeepSeek-R1 followed about four months later, together with details of a reinforcement learning with verifiable rewards (RLVR) recipe to train such reasoning models.</span></p><p>If you liked my previous <a href="https://amzn.to/4fqvn0D">Build a Large Language Model (From Scratch)</a> book, this is essentially a sequel implementing inference-time scaling techniques and reinforcement learning algorithms from scratch.</p><p>And if you want to support future long-form articles like this one, consider <a href="https://magazine.sebastianraschka.com/subscribe">becoming a paid subscriber</a>. It helps me keep writing these independent deep dives and sharing the accompanying code, figures, and experiments.</p><p></p>]]></content:encoded></item>
|
||||
<!-- Free post (audience "everyone") that ends with Substack's subscribe widget. -->
|
||||
<item><title><![CDATA[GPT-6 Astra, Looped Transformers, and Hidden Reasoning]]></title><description><![CDATA[A Look at Recurrent Depth, Hidden Chains of Thought, and Recent Research on Looping Transformer Blocks]]></description><link>https://magazine.sebastianraschka.com/p/gpt-6-astra-looped-transformers-and</link><guid isPermaLink="false">https://magazine.sebastianraschka.com/p/gpt-6-astra-looped-transformers-and</guid><dc:creator><![CDATA[Sebastian Raschka, PhD]]></dc:creator><pubDate>Wed, 09 Sep 2026 11:14:25 GMT</pubDate><content:encoded><![CDATA[<p>A lot has happened in the last few weeks. I am sure that OpenAI’s GPT-6 Astra is top of mind for everyone right now. In particular, thoughts on its performance, the looped transformer/recurrent depth aspects, and rumors that Astra is “hiding” its reasoning trace (i.e., chain of thought).</p><div class="subscription-widget-wrap-editor" data-attrs="{"url":"https://magazine.sebastianraschka.com/subscribe?","text":"Subscribe","language":"en"}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption"></p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email…" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item>
|
||||
</channel></rss>
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Interconnects AI]]></title><link>https://www.interconnects.ai</link><generator>Substack</generator>
|
||||
<!-- Real items from https://www.interconnects.ai/feed (October 2026), content:encoded trimmed to its first paragraph and its end. -->
|
||||
<!-- Paid post (audience "only_paid" on its page). -->
|
||||
<item><title><![CDATA[Lessons from the hacks]]></title><description><![CDATA[Musings on model alignment, what determines safety, and where we go from here.]]></description><link>https://www.interconnects.ai/p/lessons-from-the-hacks</link><guid isPermaLink="false">https://www.interconnects.ai/p/lessons-from-the-hacks</guid><dc:creator><![CDATA[Nathan Lambert]]></dc:creator><pubDate>Sun, 09 Aug 2026 14:57:11 GMT</pubDate><content:encoded><![CDATA[<p><span>The recent run of cyberattacks by in-development frontier models has got me thinking a lot about how our current incentive systems are not well suited for such fast technological transitions.</span></p><div><hr></div><h4>Book sale</h4><p><span>I’m also hosting a book launch where you can get a free signed copy tomorrow from 5-8PM in Seattle (Fremont/Ballard area) – we still have some extra space so I’m opening signups to paid subscribers below the paywall:</span></p>
|
||||
<p>
|
||||
<a href="https://www.interconnects.ai/p/lessons-from-the-hacks">
|
||||
Read more
|
||||
</a>
|
||||
</p>
|
||||
]]></content:encoded></item>
|
||||
<!-- Free post (audience "everyone") that mentions paid subscribers and ends with an embedded post's "Read more". -->
|
||||
<item><title><![CDATA[When will average people feel AI’s impact?]]></title><description><![CDATA[We’re <5 years into a compounding revolution which could take a century, and how the AI industry should manage this.]]></description><link>https://www.interconnects.ai/p/when-will-average-people-feel-ais</link><guid isPermaLink="false">https://www.interconnects.ai/p/when-will-average-people-feel-ais</guid><dc:creator><![CDATA[Nathan Lambert]]></dc:creator><pubDate>Wed, 09 Sep 2026 11:01:23 GMT</pubDate><content:encoded><![CDATA[<h5>Housekeeping: Paid subscribers to Interconnects now get a permanent 40% discount on my book when purchasing at Manning.com. Access the code at the Interconnects <a href="https://www.interconnects.ai/perks">perks page</a>.</h5><p>Many AI optimists tend to compare what is happening in this AI boom to the industrial revolution, or to other periods of rapid technological advancement and diffusion into society.</p><div><a class="embedded-post" native="true" href="https://www.fabricatedknowledge.com/p/mythos-and-engels-pause?utm_source=substack&utm_campaign=post_embed&utm_medium=web&embedding_publication_id=48206"><div class="embedded-post-header"><span class="embedded-post-publication-name">Fabricated Knowledge</span></div><div class="embedded-post-title-wrapper"><div class="embedded-post-title">Engels' Pause and the Permanent Underclass </div></div><div class="embedded-post-cta-wrapper"><span class="embedded-post-cta">Read more</span></div><div class="embedded-post-meta">6 months ago · 214 likes · 5 comments · Doug O'Laughlin</div></a></div>]]></content:encoded></item>
|
||||
</channel></rss>
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/services/feed_parser.dart';
|
||||
import 'package:daily_tech_news/services/feed_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'support/fake_app.dart';
|
||||
|
||||
/// Serves saved Substack feeds (trimmed real items) instead of the network,
|
||||
/// dated an hour ago so every time window keeps them.
|
||||
class _SavedFeeds extends FeedService {
|
||||
_SavedFeeds(this.fixtureByFeedUrl);
|
||||
|
||||
final Map<String, String> fixtureByFeedUrl;
|
||||
|
||||
@override
|
||||
Future<List<Article>> fetchSource(NewsSource source, {String corsProxy = ''}) async {
|
||||
final xml = File(fixtureByFeedUrl[source.feedUrl]!).readAsStringSync();
|
||||
final anHourAgo = DateTime.now().toUtc().subtract(const Duration(hours: 1));
|
||||
return [
|
||||
for (final a in FeedParser.parse(xml, source))
|
||||
Article(
|
||||
title: a.title,
|
||||
link: a.link,
|
||||
source: a.source,
|
||||
published: anHourAgo,
|
||||
excerpt: a.excerpt,
|
||||
contentHtml: a.contentHtml,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _titles(List<Article> articles) => articles.map((a) => a.title).toList();
|
||||
|
||||
void main() {
|
||||
final interconnects = testSource('interconnects', SourceKind.learning);
|
||||
final substackNews = testSource('substack-news', SourceKind.news);
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
_SavedFeeds savedFeeds() => _SavedFeeds({
|
||||
interconnects.feedUrl: 'test/fixtures/interconnects_feed.xml',
|
||||
substackNews.feedUrl: 'test/fixtures/ahead_of_ai_feed.xml',
|
||||
});
|
||||
|
||||
test('paid newsletter posts are hidden in the Learn tab and Today and counted as hidden',
|
||||
() async {
|
||||
final state = await launchApp([interconnects, substackNews], feedService: savedFeeds());
|
||||
|
||||
expect(_titles(state.articlesOf(SourceKind.learning)),
|
||||
['When will average people feel AI’s impact?']);
|
||||
expect(state.hiddenCountOf(SourceKind.learning), 1);
|
||||
expect(_titles(state.articlesOf(SourceKind.news)), [
|
||||
'Controlling Reasoning Effort in LLMs',
|
||||
'GPT-6 Astra, Looped Transformers, and Hidden Reasoning',
|
||||
]);
|
||||
expect(state.hiddenCountOf(SourceKind.news), 1);
|
||||
});
|
||||
|
||||
test('with "hide paid articles" off, paid newsletter posts show as before', () async {
|
||||
final state = await launchApp([interconnects, substackNews], feedService: savedFeeds());
|
||||
|
||||
await state.updateSettings(state.settings.copyWith(hidePaid: false));
|
||||
|
||||
expect(_titles(state.articlesOf(SourceKind.learning)).toSet(),
|
||||
{'Lessons from the hacks', 'When will average people feel AI’s impact?'});
|
||||
expect(state.hiddenCountOf(SourceKind.learning), 0);
|
||||
expect(_titles(state.articlesOf(SourceKind.news)), contains('Building an AI Text Detector From Scratch'));
|
||||
expect(state.hiddenCountOf(SourceKind.news), 0);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/services/feed_parser.dart';
|
||||
import 'package:daily_tech_news/services/paywall_service.dart';
|
||||
import 'package:daily_tech_news/services/storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -38,6 +41,21 @@ void main() {
|
||||
expect(paywall.isSponsored(_article('Anzeigenmarkt bricht ein')), isFalse);
|
||||
});
|
||||
|
||||
test('recognizes paid Substack posts by the cut-off content in the feed', () async {
|
||||
final paywall = PaywallService(await _storage(), client: MockClient((_) async => http.Response('', 500)));
|
||||
final posts = {
|
||||
for (final a in FeedParser.parse(
|
||||
File('test/fixtures/ahead_of_ai_feed.xml').readAsStringSync(), _source()))
|
||||
a.title: a,
|
||||
};
|
||||
|
||||
expect(paywall.isPaid(posts['Building an AI Text Detector From Scratch']!), isTrue);
|
||||
expect(paywall.isPaid(posts['Controlling Reasoning Effort in LLMs']!), isFalse,
|
||||
reason: 'a free post asking readers to become paid subscribers');
|
||||
expect(paywall.isPaid(posts['GPT-6 Astra, Looped Transformers, and Hidden Reasoning']!), isFalse,
|
||||
reason: 'a free post ending with the subscribe widget');
|
||||
});
|
||||
|
||||
test('checks pages only for flagged sources and remembers results', () async {
|
||||
final storage = await _storage();
|
||||
final requested = <String>[];
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
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/sources_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';
|
||||
|
||||
NewsSource _source(String id, SourceKind kind, SourceLanguage language) => NewsSource(
|
||||
id: id,
|
||||
name: id,
|
||||
feedUrl: 'https://example.com/$id.xml',
|
||||
homepage: 'https://example.com',
|
||||
language: language,
|
||||
kind: kind,
|
||||
enabledByDefault: true,
|
||||
);
|
||||
|
||||
void main() {
|
||||
final heise = _source('heise', SourceKind.news, SourceLanguage.de);
|
||||
final verge = _source('verge', SourceKind.news, SourceLanguage.en);
|
||||
final bytebytego = _source('bytebytego', SourceKind.learning, SourceLanguage.en);
|
||||
final xkcd = _source('xkcd', SourceKind.fun, SourceLanguage.en);
|
||||
|
||||
const german = '🇩🇪 Deutsch';
|
||||
const english = '🇬🇧 English';
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
Future<AppState> launch(WidgetTester tester, List<NewsSource> sources) async =>
|
||||
(await tester.runAsync(() => launchApp(sources)))!;
|
||||
|
||||
Future<void> showSources(WidgetTester tester, AppState state) async {
|
||||
tester.view.physicalSize = const Size(800, 2400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: SourcesScreen(state: state, onChanged: () {})));
|
||||
}
|
||||
|
||||
/// Asserts the [finders] each match once on screen, from top to bottom.
|
||||
void expectInOrder(WidgetTester tester, List<Finder> finders) {
|
||||
final ys = [for (final f in finders) tester.getTopLeft(f).dy];
|
||||
expect(ys, [...ys]..sort());
|
||||
expect(ys.toSet().length, ys.length);
|
||||
}
|
||||
|
||||
testWidgets('the Sources tab groups News, Learning and Fun, each split by language',
|
||||
(tester) async {
|
||||
final state = await launch(tester, [xkcd, bytebytego, verge, heise]);
|
||||
|
||||
await showSources(tester, state);
|
||||
|
||||
expectInOrder(tester, [
|
||||
find.text('News'),
|
||||
find.text(german).at(0),
|
||||
find.text('heise'),
|
||||
find.text(english).at(0),
|
||||
find.text('verge'),
|
||||
find.text('Learning'),
|
||||
find.text(english).at(1),
|
||||
find.text('bytebytego'),
|
||||
find.text('Fun'),
|
||||
find.text(english).at(2),
|
||||
find.text('xkcd'),
|
||||
]);
|
||||
});
|
||||
|
||||
testWidgets('a kind without Sources gets no section', (tester) async {
|
||||
final state = await launch(tester, [verge]);
|
||||
|
||||
await showSources(tester, state);
|
||||
|
||||
expect(find.text('News'), findsOneWidget);
|
||||
expect(find.text('Learning'), findsNothing);
|
||||
expect(find.text('Fun'), findsNothing);
|
||||
expect(find.text(german), findsNothing);
|
||||
});
|
||||
|
||||
Future<void> addFeed(WidgetTester tester, String name, {String? kind}) async {
|
||||
await tester.tap(find.text('Add feed'));
|
||||
await tester.pumpAndSettle();
|
||||
final dialog = find.byType(AlertDialog);
|
||||
await tester.enterText(
|
||||
find.descendant(of: dialog, matching: find.widgetWithText(TextField, 'Name')), name);
|
||||
await tester.enterText(
|
||||
find.descendant(of: dialog, matching: find.widgetWithText(TextField, 'Feed URL')),
|
||||
'https://example.com/$name.xml');
|
||||
if (kind != null) {
|
||||
await tester.tap(find.descendant(of: dialog, matching: find.text(kind)));
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.tap(find.descendant(of: dialog, matching: find.text('Add')));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('a feed added without picking a kind is a News source', (tester) async {
|
||||
final state = await launch(tester, [verge, bytebytego]);
|
||||
await showSources(tester, state);
|
||||
|
||||
await addFeed(tester, 'myfeed');
|
||||
|
||||
expectInOrder(tester, [find.text('News'), find.text('myfeed'), find.text('Learning')]);
|
||||
});
|
||||
|
||||
testWidgets('a feed added as Learning shows up in the Learning section', (tester) async {
|
||||
final state = await launch(tester, [verge, bytebytego]);
|
||||
await showSources(tester, state);
|
||||
|
||||
await addFeed(tester, 'myblog', kind: 'Learning');
|
||||
|
||||
expectInOrder(tester, [find.text('Learning'), find.text('myblog')]);
|
||||
});
|
||||
|
||||
testWidgets('a feed added as Fun gets a Fun section', (tester) async {
|
||||
final state = await launch(tester, [verge]);
|
||||
await showSources(tester, state);
|
||||
|
||||
await addFeed(tester, 'mycomic', kind: 'Fun');
|
||||
|
||||
expectInOrder(tester, [find.text('News'), find.text('Fun'), find.text('mycomic')]);
|
||||
});
|
||||
|
||||
testWidgets('a custom Learning source shows up in the Learn tab, not in Today',
|
||||
(tester) async {
|
||||
final feeds = FakeFeeds({
|
||||
verge.feedUrl: [('News from this morning', const Duration(hours: 2))],
|
||||
'https://example.com/myblog.xml': [('Explainer from my blog', const Duration(days: 2))],
|
||||
});
|
||||
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));
|
||||
|
||||
await tester.tap(tab('Sources'));
|
||||
await tester.pump();
|
||||
await addFeed(tester, 'myblog', kind: 'Learning');
|
||||
await tester.tap(tab('Learn'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Explainer from my blog'), findsOneWidget);
|
||||
|
||||
await tester.tap(tab('Today'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('News from this morning'), findsOneWidget);
|
||||
expect(find.text('Explainer from my blog'), findsNothing);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user