Source kinds, Learn tab and catalog update #9
No files matched your search
@@ -237,7 +237,7 @@ Reddit blocks reader view, so a meme shows the picture from the feed.
|
||||
lib/
|
||||
main.dart App entry, theme
|
||||
data/default_sources.dart Curated feed list
|
||||
models/ Article, NewsSource, DailyBriefing, AppSettings
|
||||
models/ Article, Source, DailyBriefing, AppSettings
|
||||
services/
|
||||
feed_parser.dart RSS 2.0 / RSS 1.0 / Atom parser, date parsing
|
||||
feed_service.dart Parallel fetching, dedupe, time window
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import '../models/news_source.dart';
|
||||
import '../models/source.dart';
|
||||
|
||||
/// Curated tech news feeds. All URLs were checked to return a valid RSS/Atom
|
||||
/// feed. Users can toggle them and add their own in the Sources tab.
|
||||
const defaultSources = <NewsSource>[
|
||||
const defaultSources = <Source>[
|
||||
// --- German -------------------------------------------------------------
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'heise',
|
||||
name: 'heise online',
|
||||
feedUrl: 'https://www.heise.de/rss/heise-atom.xml',
|
||||
@@ -13,7 +13,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'IT-Nachrichten, breit aufgestellt (hohes Volumen)',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'golem',
|
||||
name: 'Golem.de',
|
||||
feedUrl: 'https://rss.golem.de/rss.php?feed=ATOM1.0',
|
||||
@@ -22,7 +22,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'IT-News für Profis',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'the-decoder',
|
||||
name: 'The Decoder',
|
||||
feedUrl: 'https://the-decoder.de/feed/',
|
||||
@@ -31,7 +31,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'KI-News: Modelle, Forschung, Branche',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 't3n',
|
||||
name: 't3n',
|
||||
feedUrl: 'https://t3n.de/rss.xml',
|
||||
@@ -40,7 +40,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Digitales Business, Startups, KI',
|
||||
checkPaywallOnPage: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'computerbase',
|
||||
name: 'ComputerBase',
|
||||
feedUrl: 'https://www.computerbase.de/rss/news.xml',
|
||||
@@ -48,7 +48,7 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.de,
|
||||
description: 'Hardware, Software, Gaming',
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'spiegel-netzwelt',
|
||||
name: 'SPIEGEL Netzwelt',
|
||||
feedUrl: 'https://www.spiegel.de/netzwelt/index.rss',
|
||||
@@ -57,7 +57,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Digitales aus Sicht eines Nachrichtenmagazins',
|
||||
checkPaywallOnPage: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'zeit-digital',
|
||||
name: 'ZEIT Digital',
|
||||
feedUrl: 'https://newsfeed.zeit.de/digital/index',
|
||||
@@ -65,7 +65,7 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.de,
|
||||
description: 'Digitalpolitik und Gesellschaft',
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'netzpolitik',
|
||||
name: 'netzpolitik.org',
|
||||
feedUrl: 'https://netzpolitik.org/feed/',
|
||||
@@ -73,7 +73,7 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.de,
|
||||
description: 'Digitale Freiheitsrechte, Datenschutz',
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'heise-security',
|
||||
name: 'heise Security',
|
||||
feedUrl: 'https://www.heise.de/security/rss/news-atom.xml',
|
||||
@@ -81,7 +81,7 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.de,
|
||||
description: 'Security-Teil von heise online (heise online ausschalten, um ihn einzeln zu lesen)',
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'heise-developer',
|
||||
name: 'heise Developer',
|
||||
feedUrl: 'https://www.heise.de/developer/rss/news-atom.xml',
|
||||
@@ -89,7 +89,7 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.de,
|
||||
description: 'Developer-Teil von heise online (heise online ausschalten, um ihn einzeln zu lesen)',
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'caschy',
|
||||
name: "Caschys Blog",
|
||||
feedUrl: 'https://stadt-bremerhaven.de/feed/',
|
||||
@@ -97,7 +97,7 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.de,
|
||||
description: 'Consumer-Tech, Smartphones, Streaming',
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'derstandard-web',
|
||||
name: 'derStandard Web',
|
||||
feedUrl: 'https://www.derstandard.at/rss/web',
|
||||
@@ -105,7 +105,7 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.de,
|
||||
description: 'Tech-Ressort der österreichischen Tageszeitung',
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'borncity',
|
||||
name: 'Borns IT- und Windows-Blog',
|
||||
feedUrl: 'https://www.borncity.com/blog/feed/',
|
||||
@@ -115,7 +115,7 @@ const defaultSources = <NewsSource>[
|
||||
),
|
||||
|
||||
// --- English ------------------------------------------------------------
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'verge',
|
||||
name: 'The Verge',
|
||||
feedUrl: 'https://www.theverge.com/rss/index.xml',
|
||||
@@ -123,7 +123,7 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.en,
|
||||
description: 'Consumer tech, culture, policy',
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'arstechnica',
|
||||
name: 'Ars Technica',
|
||||
feedUrl: 'https://feeds.arstechnica.com/arstechnica/index',
|
||||
@@ -132,7 +132,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'In-depth tech and science',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'techcrunch',
|
||||
name: 'TechCrunch',
|
||||
feedUrl: 'https://techcrunch.com/feed/',
|
||||
@@ -140,7 +140,7 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.en,
|
||||
description: 'Startups, funding, big tech',
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'hackernews',
|
||||
name: 'Hacker News',
|
||||
feedUrl: 'https://hnrss.org/frontpage?points=150',
|
||||
@@ -149,7 +149,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Front page stories with 150+ points',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'techmeme',
|
||||
name: 'Techmeme',
|
||||
feedUrl: 'https://www.techmeme.com/feed.xml',
|
||||
@@ -158,7 +158,7 @@ const defaultSources = <NewsSource>[
|
||||
description: "The day's top tech stories, picked by editors",
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'lobsters',
|
||||
name: 'Lobsters',
|
||||
feedUrl: 'https://lobste.rs/rss',
|
||||
@@ -167,7 +167,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Computing links, voted by a programmer community',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'simonwillison',
|
||||
name: 'Simon Willison',
|
||||
feedUrl: 'https://simonwillison.net/atom/entries/',
|
||||
@@ -176,7 +176,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'LLMs and AI tools, tried out hands-on',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'mit-tr',
|
||||
name: 'MIT Technology Review',
|
||||
feedUrl: 'https://www.technologyreview.com/feed/',
|
||||
@@ -184,7 +184,7 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.en,
|
||||
description: 'AI, biotech, climate tech',
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'theregister',
|
||||
name: 'The Register',
|
||||
feedUrl: 'https://www.theregister.com/headlines.atom',
|
||||
@@ -192,7 +192,7 @@ const defaultSources = <NewsSource>[
|
||||
language: SourceLanguage.en,
|
||||
description: 'Enterprise IT with attitude',
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'bleepingcomputer',
|
||||
name: 'BleepingComputer',
|
||||
feedUrl: 'https://www.bleepingcomputer.com/feed/',
|
||||
@@ -202,7 +202,7 @@ const defaultSources = <NewsSource>[
|
||||
),
|
||||
|
||||
// --- Learning -----------------------------------------------------------
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'bytebytego',
|
||||
name: 'ByteByteGo',
|
||||
feedUrl: 'https://blog.bytebytego.com/feed',
|
||||
@@ -212,7 +212,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'System design explained with diagrams',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'systemdesignone',
|
||||
name: 'System Design One',
|
||||
feedUrl: 'https://newsletter.systemdesign.one/feed',
|
||||
@@ -222,7 +222,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'System design case studies of real systems',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'pragmaticengineer-blog',
|
||||
name: 'The Pragmatic Engineer (blog)',
|
||||
feedUrl: 'https://blog.pragmaticengineer.com/rss/',
|
||||
@@ -232,7 +232,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'How big tech and startups build software (free posts)',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'martinfowler',
|
||||
name: 'Martin Fowler',
|
||||
feedUrl: 'https://martinfowler.com/feed.atom',
|
||||
@@ -242,7 +242,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Software architecture, refactoring, delivery practices',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'juliaevans',
|
||||
name: 'Julia Evans',
|
||||
feedUrl: 'https://jvns.ca/atom.xml',
|
||||
@@ -252,7 +252,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Systems, networking and Unix tools, explained plainly',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'danluu',
|
||||
name: 'Dan Luu',
|
||||
feedUrl: 'https://danluu.com/atom.xml',
|
||||
@@ -262,7 +262,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Long, data-driven essays on computers and engineering',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'cloudflare-blog',
|
||||
name: 'The Cloudflare Blog',
|
||||
feedUrl: 'https://blog.cloudflare.com/rss/',
|
||||
@@ -272,7 +272,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Networking, security and incident write-ups in depth',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'netflix-techblog',
|
||||
name: 'Netflix Technology Blog',
|
||||
feedUrl: 'https://netflixtechblog.com/feed',
|
||||
@@ -282,7 +282,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Distributed systems, data and streaming at scale',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'meta-engineering',
|
||||
name: 'Engineering at Meta',
|
||||
feedUrl: 'https://engineering.fb.com/feed/',
|
||||
@@ -292,7 +292,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Infrastructure, AI and production engineering at Meta',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'github-engineering',
|
||||
name: 'GitHub Blog: Engineering',
|
||||
feedUrl: 'https://github.blog/engineering/feed/',
|
||||
@@ -302,7 +302,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'How GitHub builds and runs GitHub',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'latent-space',
|
||||
name: 'Latent Space',
|
||||
feedUrl: 'https://www.latent.space/feed',
|
||||
@@ -312,7 +312,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'AI engineering: interviews and deep dives',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'import-ai',
|
||||
name: 'Import AI',
|
||||
feedUrl: 'https://importai.substack.com/feed',
|
||||
@@ -322,7 +322,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Weekly AI research digest by Jack Clark',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'interconnects',
|
||||
name: 'Interconnects',
|
||||
feedUrl: 'https://www.interconnects.ai/feed',
|
||||
@@ -332,7 +332,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Language model training and research, analysed',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'ahead-of-ai',
|
||||
name: 'Ahead of AI',
|
||||
feedUrl: 'https://magazine.sebastianraschka.com/feed',
|
||||
@@ -342,7 +342,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'LLM research explained, by Sebastian Raschka',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'lilianweng',
|
||||
name: 'Lilian Weng',
|
||||
feedUrl: 'https://lilianweng.github.io/index.xml',
|
||||
@@ -352,7 +352,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Thorough surveys of machine learning topics',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'infoq',
|
||||
name: 'InfoQ',
|
||||
feedUrl: 'https://feed.infoq.com/',
|
||||
@@ -363,7 +363,7 @@ const defaultSources = <NewsSource>[
|
||||
),
|
||||
|
||||
// --- Fun ----------------------------------------------------------------
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'xkcd',
|
||||
name: 'xkcd',
|
||||
feedUrl: 'https://xkcd.com/atom.xml',
|
||||
@@ -373,7 +373,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Webcomic of romance, sarcasm, math and language',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'thedailywtf',
|
||||
name: 'The Daily WTF',
|
||||
feedUrl: 'https://thedailywtf.com/rss',
|
||||
@@ -383,7 +383,7 @@ const defaultSources = <NewsSource>[
|
||||
description: 'IT horror stories, bad code and error messages',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
Source(
|
||||
id: 'programmerhumor',
|
||||
name: 'r/ProgrammerHumor',
|
||||
feedUrl: 'https://www.reddit.com/r/ProgrammerHumor/top/.rss?t=day',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart' show ThemeMode;
|
||||
|
||||
import 'news_source.dart';
|
||||
import 'source.dart';
|
||||
|
||||
enum AiProvider {
|
||||
none('Off'),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'news_source.dart';
|
||||
import 'source.dart';
|
||||
|
||||
class Article {
|
||||
const Article({
|
||||
@@ -14,7 +14,7 @@ class Article {
|
||||
|
||||
final String title;
|
||||
final String link;
|
||||
final NewsSource source;
|
||||
final Source source;
|
||||
final DateTime? published;
|
||||
|
||||
/// Short plain-text teaser from the feed (description / summary).
|
||||
|
||||
@@ -20,9 +20,9 @@ enum SourceKind {
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// A Source of any [kind]; the class name predates the kinds.
|
||||
class NewsSource {
|
||||
const NewsSource({
|
||||
/// A Source of any [kind]; see GLOSSARY.md.
|
||||
class Source {
|
||||
const Source({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.feedUrl,
|
||||
@@ -59,7 +59,7 @@ class NewsSource {
|
||||
'description': description,
|
||||
};
|
||||
|
||||
factory NewsSource.custom(Map<String, dynamic> json) => NewsSource(
|
||||
factory Source.custom(Map<String, dynamic> json) => Source(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
feedUrl: json['feedUrl'] as String,
|
||||
@@ -5,7 +5,7 @@ import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/article.dart';
|
||||
import '../models/briefing.dart';
|
||||
import '../models/news_source.dart';
|
||||
import '../models/source.dart';
|
||||
import 'summarizer.dart';
|
||||
|
||||
/// Calls the Claude Messages API directly over HTTP (there is no official
|
||||
|
||||
@@ -3,13 +3,13 @@ import 'package:html/parser.dart' as html_parser;
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
import '../models/article.dart';
|
||||
import '../models/news_source.dart';
|
||||
import '../models/source.dart';
|
||||
|
||||
/// Parses RSS 2.0, RSS 1.0 (RDF) and Atom feeds into [Article]s.
|
||||
class FeedParser {
|
||||
static const excerptLength = 400;
|
||||
|
||||
static List<Article> parse(String body, NewsSource source) {
|
||||
static List<Article> parse(String body, Source source) {
|
||||
final doc = XmlDocument.parse(body);
|
||||
final root = doc.rootElement;
|
||||
if (root.name.local == 'feed') {
|
||||
@@ -27,7 +27,7 @@ class FeedParser {
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Article? _parseRssItem(XmlElement item, NewsSource source) {
|
||||
static Article? _parseRssItem(XmlElement item, Source source) {
|
||||
final title = htmlToText(_text(item, 'title'));
|
||||
final link = _text(item, 'link').trim().isNotEmpty
|
||||
? _text(item, 'link').trim()
|
||||
@@ -53,7 +53,7 @@ class FeedParser {
|
||||
);
|
||||
}
|
||||
|
||||
static Article? _parseAtomEntry(XmlElement entry, NewsSource source) {
|
||||
static Article? _parseAtomEntry(XmlElement entry, Source source) {
|
||||
final title = htmlToText(_text(entry, 'title'));
|
||||
final links = entry.childElements.where((e) => e.name.local == 'link');
|
||||
final link = links
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/article.dart';
|
||||
import '../models/news_source.dart';
|
||||
import '../models/source.dart';
|
||||
import 'feed_parser.dart';
|
||||
|
||||
class FeedResult {
|
||||
@@ -30,8 +30,8 @@ class FeedService {
|
||||
/// [corsProxy] is only used on web, where browsers block cross-origin feed
|
||||
/// requests. It is prepended to the feed URL, e.g. `https://proxy/?url=`.
|
||||
Future<FeedResult> fetchAll(
|
||||
List<NewsSource> sources, {
|
||||
required Duration Function(NewsSource source) maxAgeOf,
|
||||
List<Source> sources, {
|
||||
required Duration Function(Source source) maxAgeOf,
|
||||
String corsProxy = '',
|
||||
}) async {
|
||||
final errors = <String, String>{};
|
||||
@@ -64,7 +64,7 @@ class FeedService {
|
||||
return FeedResult(articles, errors);
|
||||
}
|
||||
|
||||
Future<List<Article>> fetchSource(NewsSource source, {String corsProxy = ''}) async {
|
||||
Future<List<Article>> fetchSource(Source source, {String corsProxy = ''}) async {
|
||||
final url = kIsWeb && corsProxy.isNotEmpty
|
||||
? '$corsProxy${Uri.encodeComponent(source.feedUrl)}'
|
||||
: source.feedUrl;
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/article.dart';
|
||||
import '../models/briefing.dart';
|
||||
import '../models/news_source.dart';
|
||||
import '../models/source.dart';
|
||||
import 'summarizer.dart';
|
||||
|
||||
/// Delegates summarizing to an existing n8n workflow via a Webhook trigger.
|
||||
|
||||
@@ -9,7 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/app_settings.dart';
|
||||
import '../models/briefing.dart';
|
||||
import '../models/news_source.dart';
|
||||
import '../models/source.dart';
|
||||
|
||||
/// Persists settings, source selection and the last briefing.
|
||||
/// The Claude API key goes to the platform keystore, everything else to
|
||||
@@ -81,15 +81,15 @@ class Storage {
|
||||
Future<void> saveSeenSourceIds(Set<String> ids) =>
|
||||
_prefs.setStringList('seen_sources', ids.toList());
|
||||
|
||||
List<NewsSource> loadCustomSources() {
|
||||
List<Source> loadCustomSources() {
|
||||
final raw = _prefs.getString('custom_sources');
|
||||
if (raw == null) return [];
|
||||
return (jsonDecode(raw) as List)
|
||||
.map((j) => NewsSource.custom(j as Map<String, dynamic>))
|
||||
.map((j) => Source.custom(j as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> saveCustomSources(List<NewsSource> sources) => _prefs.setString(
|
||||
Future<void> saveCustomSources(List<Source> sources) => _prefs.setString(
|
||||
'custom_sources', jsonEncode(sources.map((s) => s.toJson()).toList()));
|
||||
|
||||
/// Article URL -> whether its page said it's paid, and when that was checked.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import '../models/article.dart';
|
||||
import '../models/briefing.dart';
|
||||
import '../models/news_source.dart';
|
||||
import '../models/source.dart';
|
||||
|
||||
class SummarizerException implements Exception {
|
||||
SummarizerException(this.message);
|
||||
|
||||
+10
-10
@@ -6,7 +6,7 @@ import '../data/default_sources.dart';
|
||||
import '../models/app_settings.dart';
|
||||
import '../models/article.dart';
|
||||
import '../models/briefing.dart';
|
||||
import '../models/news_source.dart';
|
||||
import '../models/source.dart';
|
||||
import '../services/article_extractor.dart';
|
||||
import '../services/claude_summarizer.dart';
|
||||
import '../services/consent_service.dart';
|
||||
@@ -33,7 +33,7 @@ class AppState extends ChangeNotifier {
|
||||
this._storage, {
|
||||
FeedService? feedService,
|
||||
ArticleExtractor? extractor,
|
||||
List<NewsSource> curatedSources = defaultSources,
|
||||
List<Source> curatedSources = defaultSources,
|
||||
Summarizer? summarizer,
|
||||
}) : _feeds = feedService ?? FeedService(),
|
||||
_curated = curatedSources,
|
||||
@@ -47,7 +47,7 @@ class AppState extends ChangeNotifier {
|
||||
final FeedService _feeds;
|
||||
|
||||
/// The curated catalog; [defaultSources] outside of tests.
|
||||
final List<NewsSource> _curated;
|
||||
final List<Source> _curated;
|
||||
|
||||
/// Used instead of the AI set up in [settings]; for tests.
|
||||
final Summarizer? _summarizerOverride;
|
||||
@@ -58,7 +58,7 @@ class AppState extends ChangeNotifier {
|
||||
final ConsentService consent;
|
||||
|
||||
AppSettings settings = const AppSettings();
|
||||
List<NewsSource> customSources = [];
|
||||
List<Source> customSources = [];
|
||||
Set<String> enabledSourceIds = {};
|
||||
|
||||
/// Everything the feeds returned; see [articlesOf] for what is shown.
|
||||
@@ -99,9 +99,9 @@ class AppState extends ChangeNotifier {
|
||||
SourceKind.fun => const Duration(days: 3),
|
||||
};
|
||||
|
||||
List<NewsSource> get allSources => [..._curated, ...customSources];
|
||||
List<Source> get allSources => [..._curated, ...customSources];
|
||||
|
||||
List<NewsSource> get enabledSources =>
|
||||
List<Source> get enabledSources =>
|
||||
allSources.where((s) => enabledSourceIds.contains(s.id)).toList();
|
||||
|
||||
Future<void> init() async {
|
||||
@@ -156,7 +156,7 @@ class AppState extends ChangeNotifier {
|
||||
await _storage.saveEnabledSourceIds(enabledSourceIds);
|
||||
}
|
||||
|
||||
Future<void> setSourceEnabled(NewsSource source, bool enabled) async {
|
||||
Future<void> setSourceEnabled(Source source, bool enabled) async {
|
||||
enabled ? enabledSourceIds.add(source.id) : enabledSourceIds.remove(source.id);
|
||||
notifyListeners();
|
||||
await _storage.saveEnabledSourceIds(enabledSourceIds);
|
||||
@@ -171,7 +171,7 @@ class AppState extends ChangeNotifier {
|
||||
SourceKind kind = SourceKind.news,
|
||||
}) async {
|
||||
final uri = Uri.tryParse(feedUrl);
|
||||
final source = NewsSource(
|
||||
final source = Source(
|
||||
id: 'custom-${DateTime.now().millisecondsSinceEpoch}',
|
||||
name: name,
|
||||
feedUrl: feedUrl,
|
||||
@@ -194,7 +194,7 @@ class AppState extends ChangeNotifier {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> removeCustomSource(NewsSource source) async {
|
||||
Future<void> removeCustomSource(Source source) async {
|
||||
customSources = customSources.where((s) => s.id != source.id).toList();
|
||||
enabledSourceIds.remove(source.id);
|
||||
await _storage.saveCustomSources(customSources);
|
||||
@@ -235,7 +235,7 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
final source = allSources.firstWhere(
|
||||
(s) => s.name == sourceName,
|
||||
orElse: () => NewsSource(
|
||||
orElse: () => Source(
|
||||
id: sourceName,
|
||||
name: sourceName,
|
||||
feedUrl: '',
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
import '../models/article.dart';
|
||||
import '../models/news_source.dart';
|
||||
import '../models/source.dart';
|
||||
import '../services/article_extractor.dart';
|
||||
import '../services/consent_service.dart';
|
||||
import '../services/content_blocking.dart';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/briefing.dart';
|
||||
import '../models/news_source.dart';
|
||||
import '../models/source.dart';
|
||||
import '../state/app_state.dart';
|
||||
import 'article_screen.dart';
|
||||
import 'briefing_history_screen.dart';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/article.dart';
|
||||
import '../models/news_source.dart';
|
||||
import '../models/source.dart';
|
||||
import '../state/app_state.dart';
|
||||
import 'article_screen.dart';
|
||||
import 'common.dart';
|
||||
@@ -113,7 +113,7 @@ class _FeedScreenState extends State<FeedScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _filterBar(List<NewsSource> sources) {
|
||||
Widget _filterBar(List<Source> sources) {
|
||||
sources.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
return SizedBox(
|
||||
height: 52,
|
||||
@@ -157,7 +157,7 @@ class _FeedScreenState extends State<FeedScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
static const _placeholder = NewsSource(
|
||||
static const _placeholder = Source(
|
||||
id: '', name: 'All sources', feedUrl: '', homepage: '', language: SourceLanguage.en);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/news_source.dart';
|
||||
import '../models/source.dart';
|
||||
import '../state/app_state.dart';
|
||||
import 'article_screen.dart';
|
||||
import 'feed_screen.dart';
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/app_settings.dart';
|
||||
import '../models/news_source.dart';
|
||||
import '../models/source.dart';
|
||||
import '../state/app_state.dart';
|
||||
import 'common.dart';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/news_source.dart';
|
||||
import '../models/source.dart';
|
||||
import '../state/app_state.dart';
|
||||
|
||||
class SourcesScreen extends StatelessWidget {
|
||||
@@ -109,7 +109,7 @@ class _SourceTile extends StatelessWidget {
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
final NewsSource source;
|
||||
final Source source;
|
||||
final bool enabled;
|
||||
final ValueChanged<bool> onToggle;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/models/source.dart';
|
||||
import 'package:daily_tech_news/services/article_extractor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -88,7 +88,7 @@ void main() {
|
||||
title: 'Story',
|
||||
link: 'https://www.golem.de/news/story.html',
|
||||
excerpt: 'Teaser',
|
||||
source: NewsSource(
|
||||
source: Source(
|
||||
id: 'golem', name: 'Golem.de', feedUrl: '', homepage: '', language: SourceLanguage.de),
|
||||
);
|
||||
final content = await extractor.load(article);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/models/source.dart';
|
||||
import 'package:daily_tech_news/services/claude_summarizer.dart';
|
||||
import 'package:daily_tech_news/services/summarizer.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
const _source = NewsSource(
|
||||
const _source = Source(
|
||||
id: 'verge',
|
||||
name: 'The Verge',
|
||||
feedUrl: 'https://example.com/feed',
|
||||
|
||||
@@ -2,7 +2,7 @@ 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/models/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';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/models/source.dart';
|
||||
import 'package:daily_tech_news/services/article_extractor.dart';
|
||||
import 'package:daily_tech_news/services/consent_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -50,7 +50,7 @@ const _article = Article(
|
||||
title: 'Story',
|
||||
link: _articleUrl,
|
||||
excerpt: 'Teaser',
|
||||
source: NewsSource(
|
||||
source: Source(
|
||||
id: 'golem', name: 'Golem.de', feedUrl: '', homepage: '', language: SourceLanguage.de),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:daily_tech_news/data/default_sources.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/models/source.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
NewsSource _byId(String id) => defaultSources.singleWhere((s) => s.id == id);
|
||||
Source _byId(String id) => defaultSources.singleWhere((s) => s.id == id);
|
||||
|
||||
void main() {
|
||||
test('the News sources on by default are the curated quality set', () {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/models/source.dart';
|
||||
import 'package:daily_tech_news/services/feed_parser.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
const _source = NewsSource(
|
||||
const _source = Source(
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
feedUrl: 'https://example.com/feed',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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/models/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';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/models/source.dart';
|
||||
import 'package:daily_tech_news/state/app_state.dart';
|
||||
import 'package:daily_tech_news/ui/feed_screen.dart';
|
||||
import 'package:daily_tech_news/ui/home_shell.dart';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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/models/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';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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/models/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';
|
||||
@@ -17,7 +17,7 @@ class _SavedFeeds extends FeedService {
|
||||
final Map<String, String> fixtureByFeedUrl;
|
||||
|
||||
@override
|
||||
Future<List<Article>> fetchSource(NewsSource source, {String corsProxy = ''}) async {
|
||||
Future<List<Article>> fetchSource(Source source, {String corsProxy = ''}) async {
|
||||
final xml = File(fixtureByFeedUrl[source.feedUrl]!).readAsStringSync();
|
||||
final anHourAgo = DateTime.now().toUtc().subtract(const Duration(hours: 1));
|
||||
return [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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/models/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';
|
||||
@@ -10,7 +10,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
NewsSource _source({bool checkPage = false}) => NewsSource(
|
||||
Source _source({bool checkPage = false}) => Source(
|
||||
id: 'src',
|
||||
name: 'Source',
|
||||
feedUrl: 'https://example.com/feed',
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'dart:convert';
|
||||
import 'package:daily_tech_news/data/default_sources.dart';
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/briefing.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/models/source.dart';
|
||||
import 'package:daily_tech_news/services/summarizer.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:daily_tech_news/data/default_sources.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/models/source.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';
|
||||
@@ -9,12 +9,12 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
/// Returns no articles, so starting the app doesn't hit the network.
|
||||
class _NoFeeds extends FeedService {
|
||||
@override
|
||||
Future<FeedResult> fetchAll(List<NewsSource> sources,
|
||||
{required Duration Function(NewsSource source) maxAgeOf, String corsProxy = ''}) async =>
|
||||
Future<FeedResult> fetchAll(List<Source> sources,
|
||||
{required Duration Function(Source source) maxAgeOf, String corsProxy = ''}) async =>
|
||||
const FeedResult([], {});
|
||||
}
|
||||
|
||||
NewsSource _source(String id, {bool onByDefault = false}) => NewsSource(
|
||||
Source _source(String id, {bool onByDefault = false}) => Source(
|
||||
id: id,
|
||||
name: id,
|
||||
feedUrl: 'https://example.com/$id.xml',
|
||||
@@ -31,7 +31,7 @@ const _learningOnByDefault = {
|
||||
};
|
||||
|
||||
/// Starts the app the way a launch does, against whatever is in preferences.
|
||||
Future<AppState> _launch(List<NewsSource> curatedSources) async {
|
||||
Future<AppState> _launch(List<Source> curatedSources) async {
|
||||
final state =
|
||||
AppState(await Storage.open(), feedService: _NoFeeds(), curatedSources: curatedSources);
|
||||
await state.init();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/models/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';
|
||||
@@ -9,7 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'support/fake_app.dart';
|
||||
|
||||
NewsSource _source(String id, SourceKind kind, SourceLanguage language) => NewsSource(
|
||||
Source _source(String id, SourceKind kind, SourceLanguage language) => Source(
|
||||
id: id,
|
||||
name: id,
|
||||
feedUrl: 'https://example.com/$id.xml',
|
||||
@@ -30,7 +30,7 @@ void main() {
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
Future<AppState> launch(WidgetTester tester, List<NewsSource> sources) async =>
|
||||
Future<AppState> launch(WidgetTester tester, List<Source> sources) async =>
|
||||
(await tester.runAsync(() => launchApp(sources)))!;
|
||||
|
||||
Future<void> showSources(WidgetTester tester, AppState state) async {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/models/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';
|
||||
@@ -18,14 +18,14 @@ class FakeFeeds extends FeedService {
|
||||
int refreshes = 0;
|
||||
|
||||
@override
|
||||
Future<FeedResult> fetchAll(List<NewsSource> sources,
|
||||
{required Duration Function(NewsSource source) maxAgeOf, String corsProxy = ''}) {
|
||||
Future<FeedResult> fetchAll(List<Source> sources,
|
||||
{required Duration Function(Source source) maxAgeOf, String corsProxy = ''}) {
|
||||
refreshes++;
|
||||
return super.fetchAll(sources, maxAgeOf: maxAgeOf, corsProxy: corsProxy);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Article>> fetchSource(NewsSource source, {String corsProxy = ''}) async {
|
||||
Future<List<Article>> fetchSource(Source source, {String corsProxy = ''}) async {
|
||||
final now = DateTime.now().toUtc();
|
||||
// A feed nobody set up still has an article, so it can be added.
|
||||
if (!byFeedUrl.containsKey(source.feedUrl)) {
|
||||
@@ -43,7 +43,7 @@ class FakeFeeds extends FeedService {
|
||||
}
|
||||
}
|
||||
|
||||
NewsSource testSource(String id, SourceKind kind) => NewsSource(
|
||||
Source testSource(String id, SourceKind kind) => Source(
|
||||
id: id,
|
||||
name: id,
|
||||
feedUrl: 'https://example.com/$id.xml',
|
||||
@@ -55,7 +55,7 @@ NewsSource testSource(String id, SourceKind kind) => NewsSource(
|
||||
|
||||
/// Starts the app the way a launch does, against whatever is in preferences.
|
||||
Future<AppState> launchApp(
|
||||
List<NewsSource> curatedSources, {
|
||||
List<Source> curatedSources, {
|
||||
Map<String, List<(String, Duration)>> feeds = const {},
|
||||
FeedService? feedService,
|
||||
Summarizer? summarizer,
|
||||
|
||||
Reference in new issue
Block a user