Initial commit: Tagesbyte prototype

Flutter app that collects daily tech news from German and English RSS/Atom
feeds, with a reader view, AI article summaries and daily briefings (Claude
API or n8n webhook), paywall/sponsored filtering and ad/consent blocking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-16 14:53:37 +02:00
commit f12fb1dabb
159 files changed
+8738

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
import '../models/news_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>[
// --- German -------------------------------------------------------------
NewsSource(
id: 'heise',
name: 'heise online',
feedUrl: 'https://www.heise.de/rss/heise-atom.xml',
homepage: 'https://www.heise.de',
language: SourceLanguage.de,
description: 'IT-Nachrichten, breit aufgestellt (hohes Volumen)',
enabledByDefault: true,
),
NewsSource(
id: 'golem',
name: 'Golem.de',
feedUrl: 'https://rss.golem.de/rss.php?feed=ATOM1.0',
homepage: 'https://www.golem.de',
language: SourceLanguage.de,
description: 'IT-News für Profis',
enabledByDefault: true,
),
NewsSource(
id: 't3n',
name: 't3n',
feedUrl: 'https://t3n.de/rss.xml',
homepage: 'https://t3n.de',
language: SourceLanguage.de,
description: 'Digitales Business, Startups, KI',
enabledByDefault: true,
checkPaywallOnPage: true,
),
NewsSource(
id: 'computerbase',
name: 'ComputerBase',
feedUrl: 'https://www.computerbase.de/rss/news.xml',
homepage: 'https://www.computerbase.de',
language: SourceLanguage.de,
description: 'Hardware, Software, Gaming',
enabledByDefault: true,
),
NewsSource(
id: 'spiegel-netzwelt',
name: 'SPIEGEL Netzwelt',
feedUrl: 'https://www.spiegel.de/netzwelt/index.rss',
homepage: 'https://www.spiegel.de/netzwelt/',
language: SourceLanguage.de,
description: 'Digitales aus Sicht eines Nachrichtenmagazins',
checkPaywallOnPage: true,
),
NewsSource(
id: 'zeit-digital',
name: 'ZEIT Digital',
feedUrl: 'https://newsfeed.zeit.de/digital/index',
homepage: 'https://www.zeit.de/digital/',
language: SourceLanguage.de,
description: 'Digitalpolitik und Gesellschaft',
),
NewsSource(
id: 'netzpolitik',
name: 'netzpolitik.org',
feedUrl: 'https://netzpolitik.org/feed/',
homepage: 'https://netzpolitik.org',
language: SourceLanguage.de,
description: 'Digitale Freiheitsrechte, Datenschutz',
),
NewsSource(
id: 'heise-security',
name: 'heise Security',
feedUrl: 'https://www.heise.de/security/rss/news-atom.xml',
homepage: 'https://www.heise.de/security/',
language: SourceLanguage.de,
description: 'Sicherheitslücken und Security-News',
),
NewsSource(
id: 'heise-developer',
name: 'heise Developer',
feedUrl: 'https://www.heise.de/developer/rss/news-atom.xml',
homepage: 'https://www.heise.de/developer/',
language: SourceLanguage.de,
description: 'Softwareentwicklung',
),
NewsSource(
id: 'caschy',
name: "Caschys Blog",
feedUrl: 'https://stadt-bremerhaven.de/feed/',
homepage: 'https://stadt-bremerhaven.de',
language: SourceLanguage.de,
description: 'Consumer-Tech, Smartphones, Streaming',
),
NewsSource(
id: 'derstandard-web',
name: 'derStandard Web',
feedUrl: 'https://www.derstandard.at/rss/web',
homepage: 'https://www.derstandard.at/web',
language: SourceLanguage.de,
description: 'Tech-Ressort der österreichischen Tageszeitung',
),
NewsSource(
id: 'borncity',
name: 'Borns IT- und Windows-Blog',
feedUrl: 'https://www.borncity.com/blog/feed/',
homepage: 'https://www.borncity.com/blog/',
language: SourceLanguage.de,
description: 'Windows, Patches, Admin-Themen',
),
// --- English ------------------------------------------------------------
NewsSource(
id: 'verge',
name: 'The Verge',
feedUrl: 'https://www.theverge.com/rss/index.xml',
homepage: 'https://www.theverge.com',
language: SourceLanguage.en,
description: 'Consumer tech, culture, policy',
enabledByDefault: true,
),
NewsSource(
id: 'arstechnica',
name: 'Ars Technica',
feedUrl: 'https://feeds.arstechnica.com/arstechnica/index',
homepage: 'https://arstechnica.com',
language: SourceLanguage.en,
description: 'In-depth tech and science',
enabledByDefault: true,
),
NewsSource(
id: 'techcrunch',
name: 'TechCrunch',
feedUrl: 'https://techcrunch.com/feed/',
homepage: 'https://techcrunch.com',
language: SourceLanguage.en,
description: 'Startups, funding, big tech',
enabledByDefault: true,
),
NewsSource(
id: 'hackernews',
name: 'Hacker News',
feedUrl: 'https://news.ycombinator.com/rss',
homepage: 'https://news.ycombinator.com',
language: SourceLanguage.en,
description: 'Front page links, ranked by readers',
enabledByDefault: true,
),
NewsSource(
id: 'mit-tr',
name: 'MIT Technology Review',
feedUrl: 'https://www.technologyreview.com/feed/',
homepage: 'https://www.technologyreview.com',
language: SourceLanguage.en,
description: 'AI, biotech, climate tech',
),
NewsSource(
id: 'wired',
name: 'WIRED',
feedUrl: 'https://www.wired.com/feed/rss',
homepage: 'https://www.wired.com',
language: SourceLanguage.en,
description: 'Tech, science and culture',
),
NewsSource(
id: 'engadget',
name: 'Engadget',
feedUrl: 'https://www.engadget.com/rss.xml',
homepage: 'https://www.engadget.com',
language: SourceLanguage.en,
description: 'Gadgets and consumer electronics',
),
NewsSource(
id: 'theregister',
name: 'The Register',
feedUrl: 'https://www.theregister.com/headlines.atom',
homepage: 'https://www.theregister.com',
language: SourceLanguage.en,
description: 'Enterprise IT with attitude',
),
NewsSource(
id: 'bleepingcomputer',
name: 'BleepingComputer',
feedUrl: 'https://www.bleepingcomputer.com/feed/',
homepage: 'https://www.bleepingcomputer.com',
language: SourceLanguage.en,
description: 'Security and malware news',
),
];
+45
View File
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'services/storage.dart';
import 'state/app_state.dart';
import 'ui/common.dart';
import 'ui/home_shell.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Portrait only; also set in AndroidManifest.xml and Info.plist.
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
final state = AppState(await Storage.open());
runApp(DailyTechNewsApp(state: state));
state.init();
}
class DailyTechNewsApp extends StatelessWidget {
const DailyTechNewsApp({super.key, required this.state});
final AppState state;
static const _seed = Color(0xFF3355DD);
@override
Widget build(BuildContext context) {
ThemeData theme(Brightness brightness) => ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: _seed, brightness: brightness),
cardTheme: const CardThemeData(margin: EdgeInsets.zero),
);
// Rebuild when the theme setting changes; everything else listens itself.
return ListenableBuilder(
listenable: state,
builder: (context, _) => MaterialApp(
title: appName,
debugShowCheckedModeBanner: false,
theme: theme(Brightness.light),
darkTheme: theme(Brightness.dark),
themeMode: state.settings.themeMode,
home: HomeShell(state: state),
),
);
}
}
+102
View File
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart' show ThemeMode;
import 'news_source.dart';
enum AiProvider {
none('Off'),
claude('Claude API'),
n8n('n8n webhook');
const AiProvider(this.label);
final String label;
}
const claudeModels = {
'claude-opus-5-5': 'Claude Opus 5.5 (best quality)',
'claude-sonnet-5-5': 'Claude Sonnet 5.5 (balanced)',
'claude-haiku-5-5': 'Claude Haiku 5.5 (cheapest, fastest)',
};
class AppSettings {
const AppSettings({
this.aiProvider = AiProvider.none,
this.claudeApiKey = '',
this.claudeModel = 'claude-opus-5-5',
this.n8nWebhookUrl = '',
this.summaryLanguage = SourceLanguage.de,
this.maxAgeHours = 24,
this.corsProxy = '',
this.hidePaid = true,
this.hideSponsored = true,
this.blockAds = true,
this.hideCookieBanners = true,
this.acceptConsentWalls = true,
this.themeMode = ThemeMode.system,
});
final AiProvider aiProvider;
final String claudeApiKey;
final String claudeModel;
final String n8nWebhookUrl;
final SourceLanguage summaryLanguage;
final int maxAgeHours;
/// Web only: prefix for feed/article URLs to get around browser CORS.
final String corsProxy;
/// Hide paywalled articles (heise+, SPIEGEL+, Golem Plus, t3n Pro).
final bool hidePaid;
/// Hide sponsored posts ("Anzeige:").
final bool hideSponsored;
/// Original-page view: block ad/tracker requests and hide ad slots.
final bool blockAds;
/// Original-page view: block consent managers and hide their banners.
final bool hideCookieBanners;
/// Accept consent on sites that only show articles after accepting (Golem).
final bool acceptConsentWalls;
/// Light, dark, or follow the phone's setting.
final ThemeMode themeMode;
bool get aiConfigured => switch (aiProvider) {
AiProvider.none => false,
AiProvider.claude => claudeApiKey.isNotEmpty,
AiProvider.n8n => n8nWebhookUrl.startsWith('http'),
};
AppSettings copyWith({
AiProvider? aiProvider,
String? claudeApiKey,
String? claudeModel,
String? n8nWebhookUrl,
SourceLanguage? summaryLanguage,
int? maxAgeHours,
String? corsProxy,
bool? hidePaid,
bool? hideSponsored,
bool? blockAds,
bool? hideCookieBanners,
bool? acceptConsentWalls,
ThemeMode? themeMode,
}) =>
AppSettings(
aiProvider: aiProvider ?? this.aiProvider,
claudeApiKey: claudeApiKey ?? this.claudeApiKey,
claudeModel: claudeModel ?? this.claudeModel,
n8nWebhookUrl: n8nWebhookUrl ?? this.n8nWebhookUrl,
summaryLanguage: summaryLanguage ?? this.summaryLanguage,
maxAgeHours: maxAgeHours ?? this.maxAgeHours,
corsProxy: corsProxy ?? this.corsProxy,
hidePaid: hidePaid ?? this.hidePaid,
hideSponsored: hideSponsored ?? this.hideSponsored,
blockAds: blockAds ?? this.blockAds,
hideCookieBanners: hideCookieBanners ?? this.hideCookieBanners,
acceptConsentWalls: acceptConsentWalls ?? this.acceptConsentWalls,
themeMode: themeMode ?? this.themeMode,
);
}
+30
View File
@@ -0,0 +1,30 @@
import 'news_source.dart';
class Article {
const Article({
required this.title,
required this.link,
required this.source,
this.published,
this.excerpt = '',
this.contentHtml = '',
this.imageUrl,
this.author,
});
final String title;
final String link;
final NewsSource source;
final DateTime? published;
/// Short plain-text teaser from the feed (description / summary).
final String excerpt;
/// Full article HTML if the feed ships it (content:encoded / atom content).
final String contentHtml;
final String? imageUrl;
final String? author;
/// The link is unique enough to identify an article across refreshes.
String get id => link;
}
+81
View File
@@ -0,0 +1,81 @@
/// An AI generated digest of the day's news, grouped into topics.
class DailyBriefing {
const DailyBriefing({
required this.createdAt,
required this.intro,
required this.topics,
required this.generatedBy,
});
final DateTime createdAt;
final String intro;
final List<BriefingTopic> topics;
/// Human readable name of the summarizer, e.g. "Claude (claude-opus-5-5)".
final String generatedBy;
Map<String, dynamic> toJson() => {
'createdAt': createdAt.toIso8601String(),
'intro': intro,
'generatedBy': generatedBy,
'topics': topics.map((t) => t.toJson()).toList(),
};
factory DailyBriefing.fromJson(Map<String, dynamic> json) => DailyBriefing(
createdAt: DateTime.parse(json['createdAt'] as String),
intro: json['intro'] as String? ?? '',
generatedBy: json['generatedBy'] as String? ?? '',
topics: (json['topics'] as List? ?? [])
.map((t) => BriefingTopic.fromJson(t as Map<String, dynamic>))
.toList(),
);
}
class BriefingTopic {
const BriefingTopic({
required this.title,
required this.summary,
required this.sources,
});
final String title;
final String summary;
final List<ArticleRef> sources;
Map<String, dynamic> toJson() => {
'title': title,
'summary': summary,
'sources': sources.map((s) => s.toJson()).toList(),
};
factory BriefingTopic.fromJson(Map<String, dynamic> json) => BriefingTopic(
title: json['title'] as String? ?? '',
summary: json['summary'] as String? ?? '',
sources: (json['sources'] as List? ?? [])
.map((s) => ArticleRef.fromJson(s as Map<String, dynamic>))
.toList(),
);
}
/// A lightweight pointer to an article, stored with the briefing so links
/// still work after the feed has been refreshed.
class ArticleRef {
const ArticleRef({
required this.title,
required this.url,
required this.sourceName,
});
final String title;
final String url;
final String sourceName;
Map<String, dynamic> toJson() =>
{'title': title, 'url': url, 'sourceName': sourceName};
factory ArticleRef.fromJson(Map<String, dynamic> json) => ArticleRef(
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
sourceName: json['sourceName'] as String? ?? '',
);
}
+55
View File
@@ -0,0 +1,55 @@
enum SourceLanguage {
de('Deutsch', '🇩🇪'),
en('English', '🇬🇧');
const SourceLanguage(this.label, this.flag);
final String label;
final String flag;
}
class NewsSource {
const NewsSource({
required this.id,
required this.name,
required this.feedUrl,
required this.homepage,
required this.language,
this.description = '',
this.enabledByDefault = false,
this.isCustom = false,
this.checkPaywallOnPage = false,
});
final String id;
final String name;
final String feedUrl;
final String homepage;
final SourceLanguage language;
final String description;
final bool enabledByDefault;
final bool isCustom;
/// Paid articles are only marked inside the page (schema.org), not in the
/// feed, so each new article page is checked once.
final bool checkPaywallOnPage;
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'feedUrl': feedUrl,
'homepage': homepage,
'language': language.name,
'description': description,
};
factory NewsSource.custom(Map<String, dynamic> json) => NewsSource(
id: json['id'] as String,
name: json['name'] as String,
feedUrl: json['feedUrl'] as String,
homepage: json['homepage'] as String? ?? '',
language: SourceLanguage.values.byName(json['language'] as String? ?? 'en'),
description: json['description'] as String? ?? '',
isCustom: true,
);
}
+250
View File
@@ -0,0 +1,250 @@
import 'package:flutter/foundation.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser;
import 'package:http/http.dart' as http;
import '../models/article.dart';
import 'consent_service.dart';
import 'content_blocking.dart';
import 'feed_service.dart';
enum BlockType { heading, paragraph, listItem, quote, code, image, caption }
/// One piece of an article in reader view. For images [text] is the URL.
class ReaderBlock {
const ReaderBlock(this.type, this.text);
final BlockType type;
final String text;
bool get isText => type != BlockType.image && type != BlockType.caption;
}
class ArticleContent {
ArticleContent(this.blocks, {this.consentWall = false})
: plainText = blocks.where((b) => b.isText).map((b) => b.text).join('\n\n');
static final empty = ArticleContent(const []);
final List<ReaderBlock> blocks;
/// Text handed to the AI summarizer.
final String plainText;
/// The site redirected to a consent page (accept tracking or subscribe)
/// instead of serving the article.
final bool consentWall;
}
/// Turns an article page into reader-view blocks so it can be read inside the
/// app, and gives the AI more to work with than the feed teaser.
class ArticleExtractor {
ArticleExtractor({http.Client? client, ConsentService? consent})
: _client = client ?? http.Client(),
_consent = consent;
final http.Client _client;
final ConsentService? _consent;
/// 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.
///
/// With [acceptConsentWalls], a redirect to a consent page the
/// [ConsentService] knows is accepted once and the article fetched again.
Future<ArticleContent> load(
Article article, {
String corsProxy = '',
bool acceptConsentWalls = false,
}) async {
final base = Uri.parse(article.link);
final fromFeed = article.contentHtml.isEmpty
? ArticleContent.empty
: parse(article.contentHtml, base, isFragment: true);
if (kIsWeb && corsProxy.isEmpty) return fromFeed;
try {
final url = Uri.parse(
kIsWeb ? '$corsProxy${Uri.encodeComponent(article.link)}' : article.link);
var page = await _fetch(url);
final consent = _consent;
if (page.wallUrl != null &&
acceptConsentWalls &&
consent != null &&
consent.canAccept(page.wallUrl!) &&
await consent.accept(page.wallUrl!, wallHtml: FeedService.decodeBody(page.bytes))) {
page = await _fetch(url);
}
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;
} catch (_) {
return fromFeed;
}
}
/// [wallUrl] is set when the request ended on a consent page.
Future<({int status, List<int> bytes, Uri? wallUrl})> _fetch(Uri url) async {
final request = http.Request('GET', url);
if (!kIsWeb) {
request.headers['User-Agent'] = 'Mozilla/5.0 (DailyTechNews)';
final cookie = _consent?.cookieHeader(url);
if (cookie != null) request.headers['Cookie'] = cookie;
}
// send() (unlike get()) keeps the final URL after redirects.
final response = await _client.send(request).timeout(const Duration(seconds: 20));
final finalUrl = response is http.BaseResponseWithUrl
? (response as http.BaseResponseWithUrl).url
: null;
final onWall =
finalUrl != null && ContentBlocking.consentWall.hasMatch(finalUrl.toString());
if (response.statusCode != 200) {
await response.stream.listen(null).cancel();
return (status: response.statusCode, bytes: const <int>[], wallUrl: null);
}
final bytes = await response.stream.toBytes().timeout(const Duration(seconds: 20));
return (status: 200, bytes: bytes, wallUrl: onWall ? finalUrl : null);
}
static const _junkTags =
'script, style, nav, header, footer, aside, form, noscript, button, svg, iframe, '
'template, dialog, '
// Screen-reader-only text such as "(öffnet im neuen Fenster)".
'.sr-only, .visually-hidden, .visuallyhidden, .screen-reader-text, .go-vh';
/// 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',
caseSensitive: false,
);
static const _containerTags = {'div', 'section', 'ul', 'ol', 'span', 'aside'};
static ArticleContent parse(String html, Uri base, {bool isFragment = false}) {
final root = isFragment
? (html_parser.parse('<body>$html</body>').body!)
: html_parser.parse(html).documentElement!;
root.querySelectorAll(_junkTags).forEach((dom.Element e) => e.remove());
for (final e in root.querySelectorAll('*').toList()) {
if (!_containerTags.contains(e.localName)) continue;
final marker = '${e.className} ${e.id}';
if (_junkMarker.hasMatch(marker)) e.remove();
}
// Many sites wrap every teaser in its own <article>, so pick whichever
// candidate container holds the most paragraph text.
dom.Element? best;
var bestScore = 0;
for (final candidate
in root.querySelectorAll('article, main, [itemprop="articleBody"]')) {
final score = _score(candidate);
if (score > bestScore) {
best = candidate;
bestScore = score;
}
}
// The body includes navigation and comments; only use it as a last resort.
best ??= isFragment ? root : root.querySelector('body');
if (best == null) return ArticleContent.empty;
final blocks = <ReaderBlock>[];
final seenImages = <String>{};
var chars = 0;
void add(BlockType type, String text) {
if (chars > maxChars) return;
if (type == BlockType.image) {
if (!seenImages.add(text)) return;
} else {
chars += text.length;
}
blocks.add(ReaderBlock(type, text));
}
void walk(dom.Element el) {
switch (el.localName) {
case 'h1':
return; // The title is shown from the feed.
case 'h2' || 'h3' || 'h4':
final t = _clean(el.text);
if (t.isNotEmpty) add(BlockType.heading, t);
return;
case 'p':
final t = _clean(el.text);
if (t.length >= 25) add(BlockType.paragraph, t);
el.querySelectorAll('img').forEach((img) => _addImage(img, base, add));
return;
case 'li':
// Nested lists: emit the inner items individually.
if (el.querySelector('ul, ol') != null) break;
final t = _clean(el.text);
// Items that are nothing but a link are tables of contents or nav.
final linkText = _clean(el.querySelectorAll('a').map((a) => a.text).join(' '));
if (linkText == t) return;
if (t.length >= 20) add(BlockType.listItem, t);
return;
case 'blockquote':
final t = _clean(el.text);
if (t.isNotEmpty) add(BlockType.quote, t);
return;
case 'pre':
final t = el.text.trimRight();
if (t.isNotEmpty) add(BlockType.code, t);
return;
case 'figcaption':
final t = _clean(el.text);
if (t.isNotEmpty) add(BlockType.caption, t);
return;
case 'img':
_addImage(el, base, add);
return;
}
for (final child in el.children) {
walk(child);
}
}
walk(best);
return ArticleContent(blocks);
}
static int _score(dom.Element root) => root
.querySelectorAll('p')
.map((e) => _clean(e.text).length)
.where((l) => l > 40)
.fold(0, (a, b) => a + b);
static String _clean(String s) => s.replaceAll(RegExp(r'\s+'), ' ').trim();
static void _addImage(
dom.Element img, Uri base, void 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'] ??
img.attributes['src'] ??
img.attributes['srcset']?.split(',').first.trim().split(' ').first;
if (raw == null || raw.isEmpty || raw.startsWith('data:') || raw.endsWith('.svg')) {
return;
}
final width = int.tryParse(img.attributes['width'] ?? '');
if (width != null && width < 120) return; // icons, avatars, tracking pixels
// Images linking to another page are author avatars or related-story
// thumbnails; links to the full-size image file 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)) {
return;
}
break;
}
}
final url = base.resolve(raw).toString();
if (url.startsWith('http')) add(BlockType.image, url);
}
}
+175
View File
@@ -0,0 +1,175 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../models/article.dart';
import '../models/briefing.dart';
import '../models/news_source.dart';
import 'summarizer.dart';
/// Calls the Claude Messages API directly over HTTP (there is no official
/// Dart SDK).
class ClaudeSummarizer implements Summarizer {
ClaudeSummarizer({required this.apiKey, required this.model, http.Client? client})
: _client = client ?? http.Client();
final String apiKey;
final String model;
final http.Client _client;
static final _endpoint = Uri.parse('https://api.anthropic.com/v1/messages');
/// Models that support the server-side refusal fallback (`fallbacks: "default"`).
static const _fallbackModels = {'claude-opus-5-5', 'claude-sonnet-5-5'};
static const _retryStatus = {429, 500, 502, 503, 529};
@override
String get name => 'Claude ($model)';
@override
Future<String> summarizeArticle(
Article article, String text, SourceLanguage language) async {
final system = 'You summarize tech news articles for a busy reader. '
'Write in ${languageName(language)}, regardless of the article language. '
'Start with a single-sentence TL;DR line prefixed with "TL;DR:", then 3-5 '
'short bullet points starting with "• " covering the key facts, numbers and '
'why it matters. Plain text only, no markdown headings or bold. If the text '
'is only a teaser, summarize what is there and say that the full article '
'was not available.';
final user = 'Source: ${article.source.name}\n'
'Title: ${article.title}\n'
'URL: ${article.link}\n\n'
'Article text:\n$text';
return _send(system: system, user: user);
}
@override
Future<DailyBriefing> createBriefing(
List<Article> articles, SourceLanguage language) async {
final input = BriefingInput(articles);
final system = 'You are the editor of a daily tech news briefing. You receive '
"today's headlines from several German and English tech news sites, each "
'with an id in square brackets. Pick the 6-10 most important stories for a '
'software developer interested in tech, merging coverage of the same story '
'from different sources into one topic. Prefer substantial news (releases, '
'security incidents, regulation, big business moves, AI) over deals, '
'reviews and opinion pieces. Write all text in ${languageName(language)}. '
'For each topic give a short title, a 2-4 sentence summary, and the ids of '
'the articles it is based on. The intro is one or two sentences about the '
"day's overall picture.";
final user = "Today's headlines:\n\n${input.toPromptList()}";
final raw = await _send(system: system, user: user, schema: _briefingSchema);
try {
return input.toBriefing(jsonDecode(raw) as Map<String, dynamic>, name);
} on FormatException {
throw SummarizerException('Claude returned an unexpected briefing format.');
}
}
static const _briefingSchema = {
'type': 'object',
'properties': {
'intro': {'type': 'string'},
'topics': {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'title': {'type': 'string'},
'summary': {'type': 'string'},
'article_ids': {
'type': 'array',
'items': {'type': 'string'},
},
},
'required': ['title', 'summary', 'article_ids'],
'additionalProperties': false,
},
},
},
'required': ['intro', 'topics'],
'additionalProperties': false,
};
Future<String> _send({
required String system,
required String user,
Map<String, dynamic>? schema,
}) async {
final useFallbacks = _fallbackModels.contains(model);
final body = jsonEncode({
'model': model,
'max_tokens': 16000,
'system': system,
'messages': [
{'role': 'user', 'content': user},
],
// Summarizing is a routine task; low effort keeps it fast and cheap.
'output_config': {
'effort': 'low',
if (schema != null) 'format': {'type': 'json_schema', 'schema': schema},
},
// If a safety classifier declines, retry server-side on a fallback model.
if (useFallbacks) 'fallbacks': 'default',
});
final headers = {
'content-type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
if (useFallbacks) 'anthropic-beta': 'server-side-fallback-2026-07-01',
if (kIsWeb) 'anthropic-dangerous-direct-browser-access': 'true',
};
http.Response response;
var attempt = 0;
while (true) {
try {
response = await _client
.post(_endpoint, headers: headers, body: body)
.timeout(const Duration(minutes: 3));
} on Exception catch (e) {
if (attempt++ < 2) {
await Future.delayed(Duration(seconds: 2 * attempt));
continue;
}
throw SummarizerException('Could not reach the Claude API: $e');
}
if (_retryStatus.contains(response.statusCode) && attempt++ < 2) {
final retryAfter = int.tryParse(response.headers['retry-after'] ?? '');
await Future.delayed(Duration(seconds: retryAfter ?? 3 * attempt));
continue;
}
break;
}
final json = jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
if (response.statusCode != 200) {
final error = json['error'] as Map<String, dynamic>?;
final message = error?['message'] ?? 'Unknown error';
throw SummarizerException(switch (response.statusCode) {
401 => 'Invalid Claude API key. Check it in Settings.',
429 => 'Rate limited by the Claude API. Try again in a minute.',
529 => 'The Claude API is overloaded right now. Try again shortly.',
_ => 'Claude API error ${response.statusCode}: $message',
});
}
switch (json['stop_reason']) {
case 'refusal':
throw SummarizerException('Claude declined to summarize this content.');
case 'max_tokens' when schema != null:
throw SummarizerException('The briefing was cut off. Try fewer sources.');
}
final text = (json['content'] as List)
.where((block) => block['type'] == 'text')
.map((block) => block['text'] as String)
.join()
.trim();
if (text.isEmpty) throw SummarizerException('Claude returned an empty response.');
return text;
}
}
+104
View File
@@ -0,0 +1,104 @@
import 'package:http/http.dart' as http;
/// Gets past consent walls: sites that redirect to a separate consent page
/// instead of showing the article (currently Golem).
///
/// Accepting makes the same request the site's own "accept" button makes and
/// keeps the resulting cookies in memory, so both the reader view (via
/// [cookieHeader]) and the in-app web view (via [cookiesFor]) can send them.
/// Ads and trackers stay blocked separately.
class ConsentService {
ConsentService({http.Client? client}) : _client = client ?? http.Client();
final http.Client _client;
/// Domain (e.g. "golem.de") -> cookie name -> value.
final Map<String, Map<String, String>> _cookies = {};
static final _walls = <_ConsentWall>[_GolemWall()];
static String domainOf(Uri url) {
final parts = url.host.split('.');
return parts.length <= 2 ? url.host : parts.sublist(parts.length - 2).join('.');
}
/// True if [url] is a consent page this service knows how to accept.
bool canAccept(Uri url) => _walls.any((w) => w.matches(url));
Map<String, String> cookiesFor(Uri url) => _cookies[domainOf(url)] ?? const {};
/// Value for a `Cookie` request header, or null if nothing was accepted.
String? cookieHeader(Uri url) {
final cookies = cookiesFor(url);
if (cookies.isEmpty) return null;
return cookies.entries.map((e) => '${e.key}=${e.value}').join('; ');
}
/// Accepts the consent wall at [wallUrl]. [wallHtml] is the consent page,
/// fetched if not given. Returns true if consent cookies were obtained.
Future<bool> accept(Uri wallUrl, {String? wallHtml}) async {
final wall = _walls.where((w) => w.matches(wallUrl)).firstOrNull;
if (wall == null) return false;
try {
wallHtml ??= (await _client.get(wallUrl, headers: _headers)
.timeout(const Duration(seconds: 15)))
.body;
final response = await _client
.get(wall.acceptUrl(wallUrl, wallHtml), headers: _headers)
.timeout(const Duration(seconds: 15));
if (response.statusCode != 200) return false;
final cookies = parseSetCookie(response.headers['set-cookie'] ?? '');
if (!cookies.containsKey(wall.requiredCookie)) return false;
_cookies.putIfAbsent(domainOf(wallUrl), () => {}).addAll(cookies);
return true;
} catch (_) {
return false;
}
}
static const _headers = {'User-Agent': 'Mozilla/5.0 (DailyTechNews)'};
/// package:http joins multiple Set-Cookie headers with commas. Split only on
/// commas that start a new `name=` pair, not those inside `expires=` dates.
static Map<String, String> parseSetCookie(String header) {
final cookies = <String, String>{};
for (final cookie in header.split(RegExp(r',(?=\s*[^;,=\s]+=)'))) {
final pair = cookie.split(';').first.trim();
final eq = pair.indexOf('=');
if (eq > 0) cookies[pair.substring(0, eq)] = pair.substring(eq + 1);
}
return cookies;
}
}
abstract class _ConsentWall {
bool matches(Uri url);
/// The cookie that proves consent was stored.
String get requiredCookie;
Uri acceptUrl(Uri wallUrl, String wallHtml);
}
/// Golem redirects to /sonstiges/zustimmung/auswahl.html. Its consent script
/// stores the choice by calling /abo/setconsentcookie.php; "simple" is the
/// plain "Zustimmen" button Golem shows when its consent manager doesn't load.
class _GolemWall implements _ConsentWall {
static final _version = RegExp(r'"cookieVersion"\s*:\s*"([^"]+)"');
@override
bool matches(Uri url) =>
url.host.endsWith('golem.de') && url.path.startsWith('/sonstiges/zustimmung/');
@override
String get requiredCookie => 'golem_consent20';
@override
Uri acceptUrl(Uri wallUrl, String wallHtml) => Uri.https('www.golem.de', '/abo/setconsentcookie.php', {
'type': 'simple',
// Golem bumps this when its consent text changes; read it from the page.
'version': _version.firstMatch(wallHtml)?.group(1) ?? '|250101',
'expire': '1',
'referer': wallUrl.queryParameters['from'] ?? 'https://www.golem.de/',
});
}
+136
View File
@@ -0,0 +1,136 @@
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
/// Ad, tracker and cookie-banner blocking for the in-app "original page" view.
///
/// Two layers: requests to known ad/tracker/consent hosts are blocked before
/// they load, and a stylesheet injected at page start hides whatever slots and
/// banners are left. Hiding a consent banner gives no consent, so sites treat
/// it like a rejection.
class ContentBlocking {
/// Sites that redirect to a separate consent page instead of showing a
/// banner. Nothing can be hidden there; it's accept, pay, or leave.
static final consentWall = RegExp(r'golem\.de/sonstiges/zustimmung/|consent\.yahoo\.com/');
static const adHosts = [
// Ad exchanges and networks
'doubleclick.net', 'googlesyndication.com', 'googleadservices.com',
'googletagservices.com', 'adservice.google.com', 'adservice.google.de',
'amazon-adsystem.com', 'criteo.com', 'criteo.net', 'taboola.com', 'outbrain.com',
'adnxs.com', 'rubiconproject.com', 'pubmatic.com', 'openx.net', 'casalemedia.com',
'indexww.com', 'smartadserver.com', 'sascdn.com', 'yieldlab.net', 'adform.net',
'adition.com', 'teads.tv', 'seedtag.com', '3lift.com', 'sharethrough.com',
'33across.com', 'media.net', 'mgid.com', 'zemanta.com', 'yieldmo.com',
'onetag-sys.com', 'bidswitch.net', 'lijit.com', 'sonobi.com', 'gumgum.com',
'adsrvr.org', 'plista.com', 'ligatus.com', 'nativendo.de', 'traffective.com',
'btloader.com', 'ad-delivery.net', 'adsafeprotected.com', 'moatads.com',
'confiant-integrations.net', 'flashtalking.com', 'servedby-buysellads.com',
// Tracking and analytics
'google-analytics.com', 'googletagmanager.com', 'scorecardresearch.com',
'chartbeat.com', 'chartbeat.net', 'ioam.de', 'iocnt.net', 'hotjar.com',
'facebook.net', 'ads-twitter.com', 'analytics.tiktok.com', 'bat.bing.com',
'quantserve.com', 'krxd.net', 'demdex.net', 'omtrdc.net', 'adobedtm.com',
'permutive.com', 'permutive.app', 'cxense.com', 'id5-sync.com', 'rlcdn.com',
'bluekai.com', 'everesttech.net', 'parsely.com', 'newrelic.com', 'nr-data.net',
];
static const consentHosts = [
'privacy-mgmt.com', 'sp-prod.net', 'sourcepoint.com', // Sourcepoint
'cookielaw.org', 'onetrust.com', 'onetrust.io', // OneTrust
'usercentrics.eu', 'usercentrics.com', 'privacy-center.org', 'didomi.io',
'consensu.org', 'cookiebot.com', 'consentmanager.net', 'trustarc.com',
'truste.com', 'cookieinformation.com', 'iubenda.com', 'cookie-script.com',
'termly.io', 'osano.com', 'contentpass.net', 'contentpass.de', 'fundingchoicesmessages.google.com',
];
static const _adSelectors = [
'ins.adsbygoogle', '.adsbygoogle', '[id^="google_ads_iframe"]', '[id^="div-gpt-ad"]',
'[data-google-query-id]', 'iframe[src*="doubleclick"]', '[id*="taboola"]',
'[class*="taboola"]', '[id*="outbrain"]', '.OUTBRAIN', '[data-ad-slot]',
'[data-ad-unit]', '.ad-container', '.ad-slot', '.ad-wrapper', '.ad-label',
'.advertisement', '.advert', '.adzone', '.adunit', '.sponsored-content',
'[aria-label="Advertisement"]', '[data-testid*="ad-slot"]',
// Reserved slots that would otherwise stay as empty gaps (e.g. ComputerBase).
'[class*="adbox"]', '[class*="adslot"]', '[class*="ad-slot"]', '[class*="ad_slot"]',
'[id*="adslot"]', r'[class$="-ad"]', '[class*="-ad "]', '[class*="ad-container"]',
];
static const _consentSelectors = [
'[id^="sp_message_container"]', '.sp_veil', '#onetrust-consent-sdk',
'#onetrust-banner-sdk', '.onetrust-pc-dark-filter', '#usercentrics-root',
'#usercentrics-cmp-ui', '#didomi-host', '#CybotCookiebotDialog',
'#CybotCookiebotDialogBodyUnderlay', '#cmpbox', '#cmpbox2', '.cmpboxBG',
'#qc-cmp2-container', '.qc-cmp2-container', '#truste-consent-track',
'.truste_overlay', '.truste_box_overlay', '#iubenda-cs-banner', '.fc-consent-root',
'.fc-dialog-overlay', '#cookie-banner', '#cookiebanner', '#cookie-consent',
'.cookie-banner', '.cookie-consent', '.cc-window', '.cc-banner',
];
static String _hostPattern(List<String> hosts) =>
r'^https?://([^/:]+\.)?(' + hosts.map(RegExp.escape).join('|') + r')([:/?]|$)';
/// Network-level blocking. One combined regex per category keeps the
/// per-request check cheap.
static List<ContentBlocker> blockers({required bool ads, required bool cookies}) => [
if (ads) _block(_hostPattern(adHosts)),
if (cookies) ...[
_block(_hostPattern(consentHosts)),
// Sourcepoint is often served from the site itself, e.g. cmp.heise.de.
_block(r'^https?://cmp\.[^/]+/'),
],
];
static ContentBlocker _block(String pattern) => ContentBlocker(
trigger: ContentBlockerTrigger(urlFilter: pattern),
action: ContentBlockerAction(type: ContentBlockerActionType.BLOCK),
);
/// A script for page start that injects the hiding stylesheet and keeps it
/// in place. Returns null when nothing is enabled.
static String? cosmeticScript({required bool ads, required bool cookies}) {
final selectors = [if (ads) ..._adSelectors, if (cookies) ..._consentSelectors];
if (selectors.isEmpty) return null;
final css = StringBuffer('${selectors.join(',')}{display:none!important}');
if (cookies) {
// Consent dialogs lock scrolling on <html>/<body>; undo that.
css.write('html,body{overflow:auto!important;overflow-y:auto!important}');
}
final cssLiteral = css.toString().replaceAll(r'\', r'\\').replaceAll("'", r"\'");
return '''
(function () {
var css = '$cssLiteral';
var unlockConsent = $cookies;
var consentPattern = /consent|cookie|cmp|gdpr|sp-message|didomi|onetrust|usercentrics/i;
function apply() {
if (document.getElementById('tagesbyte-blocker')) return;
var style = document.createElement('style');
style.id = 'tagesbyte-blocker';
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
}
// Some sites (e.g. ComputerBase) build their own consent wall as a modal
// <dialog>, which makes the rest of the page inert even when hidden.
function unlock() {
if (!unlockConsent) return;
document.querySelectorAll('dialog').forEach(function (d) {
if (consentPattern.test(d.className + ' ' + d.id)) {
try { d.close(); } catch (e) {}
d.remove();
}
});
[document.documentElement, document.body].forEach(function (el) {
if (!el) return;
Array.prototype.slice.call(el.classList).forEach(function (c) {
if (consentPattern.test(c)) el.classList.remove(c);
});
});
}
apply();
document.addEventListener('DOMContentLoaded', function () { apply(); unlock(); });
window.addEventListener('load', function () { apply(); unlock(); });
// Consent scripts often open their dialog a moment after load.
var runs = 0;
var timer = setInterval(function () { unlock(); if (++runs > 30) clearInterval(timer); }, 500);
})();
''';
}
}
+198
View File
@@ -0,0 +1,198 @@
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser;
import 'package:xml/xml.dart';
import '../models/article.dart';
import '../models/news_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) {
final doc = XmlDocument.parse(body);
final root = doc.rootElement;
if (root.name.local == 'feed') {
return root.childElements
.where((e) => e.name.local == 'entry')
.map((e) => _parseAtomEntry(e, source))
.whereType<Article>()
.toList();
}
// RSS 2.0 nests <item> in <channel>, RSS 1.0 puts them at the root.
return root.descendantElements
.where((e) => e.name.local == 'item')
.map((e) => _parseRssItem(e, source))
.whereType<Article>()
.toList();
}
static Article? _parseRssItem(XmlElement item, NewsSource source) {
final title = htmlToText(_text(item, 'title'));
final link = _text(item, 'link').trim().isNotEmpty
? _text(item, 'link').trim()
: _text(item, 'guid').trim();
if (title.isEmpty || !link.startsWith('http')) return null;
final descriptionHtml = _text(item, 'description');
final contentHtml = _text(item, 'encoded', prefix: 'content');
final date = _text(item, 'pubDate').isNotEmpty
? _text(item, 'pubDate')
: _text(item, 'date', prefix: 'dc');
return Article(
title: title,
link: link,
source: source,
published: parseDate(date),
excerpt: _excerpt(descriptionHtml.isNotEmpty ? descriptionHtml : contentHtml),
contentHtml: contentHtml,
imageUrl: _imageFrom(item) ?? _firstImg(contentHtml) ?? _firstImg(descriptionHtml),
author: _nonEmpty(htmlToText(_text(item, 'creator', prefix: 'dc'))) ??
_nonEmpty(_text(item, 'author')),
);
}
static Article? _parseAtomEntry(XmlElement entry, NewsSource source) {
final title = htmlToText(_text(entry, 'title'));
final links = entry.childElements.where((e) => e.name.local == 'link');
final link = links
.where((l) => (l.getAttribute('rel') ?? 'alternate') == 'alternate')
.map((l) => l.getAttribute('href'))
.firstWhere((h) => h != null, orElse: () => null) ??
links.map((l) => l.getAttribute('href')).firstWhere((h) => h != null, orElse: () => null);
if (title.isEmpty || link == null || !link.startsWith('http')) return null;
final summaryHtml = _text(entry, 'summary');
final contentHtml = _text(entry, 'content');
final date = _text(entry, 'published').isNotEmpty
? _text(entry, 'published')
: _text(entry, 'updated');
final authorEl = _child(entry, 'author');
return Article(
title: title,
link: link,
source: source,
published: parseDate(date),
excerpt: _excerpt(summaryHtml.isNotEmpty ? summaryHtml : contentHtml),
contentHtml: contentHtml,
imageUrl: _imageFrom(entry) ?? _firstImg(contentHtml) ?? _firstImg(summaryHtml),
author: authorEl == null ? null : _nonEmpty(_text(authorEl, 'name')),
);
}
static XmlElement? _child(XmlElement parent, String local, {String? prefix}) {
for (final e in parent.childElements) {
if (e.name.local == local && (prefix == null || e.name.prefix == prefix)) {
return e;
}
}
return null;
}
static String _text(XmlElement parent, String local, {String? prefix}) =>
_child(parent, local, prefix: prefix)?.innerText.trim() ?? '';
static String? _nonEmpty(String s) => s.trim().isEmpty ? null : s.trim();
static String? _imageFrom(XmlElement item) {
for (final e in item.descendantElements) {
final local = e.name.local;
final url = e.getAttribute('url');
if (url == null) continue;
if (local == 'thumbnail') return url;
if (local == 'content' && e.name.prefix == 'media') {
final medium = e.getAttribute('medium') ?? e.getAttribute('type') ?? 'image';
if (medium.startsWith('image')) return url;
}
if (local == 'enclosure' && (e.getAttribute('type') ?? '').startsWith('image')) {
return url;
}
}
return null;
}
static String? _firstImg(String html) {
if (!html.contains('<img')) return null;
for (final img in html_parser.parseFragment(html).querySelectorAll('img')) {
// Skip tracking pixels (e.g. Golem's 1x1 counter image) and icons.
final width = int.tryParse(img.attributes['width'] ?? '');
final height = int.tryParse(img.attributes['height'] ?? '');
if ((width != null && width < 100) || (height != null && height < 100)) continue;
final src = img.attributes['src'];
if (src != null && src.startsWith('http')) return src;
}
return null;
}
static String _excerpt(String html) {
final text = htmlToText(html);
// Some feeds (e.g. Hacker News) only carry a "Comments" link here.
if (text.length < 20) return '';
if (text.length <= excerptLength) return text;
final cut = text.substring(0, excerptLength);
final lastSpace = cut.lastIndexOf(' ');
return '${cut.substring(0, lastSpace > 0 ? lastSpace : excerptLength)}…';
}
/// Converts an HTML snippet to whitespace-normalized plain text.
static String htmlToText(String html) {
if (html.isEmpty) return '';
final fragment = html_parser.parseFragment(html);
fragment.querySelectorAll('script, style').forEach((dom.Element e) => e.remove());
return (fragment.text ?? '').replaceAll(RegExp(r'\s+'), ' ').trim();
}
static const _months = {
'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6,
'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12,
};
static const _zones = {
'UT': 0, 'GMT': 0, 'Z': 0, 'UTC': 0,
'EST': -5, 'EDT': -4, 'CST': -6, 'CDT': -5,
'MST': -7, 'MDT': -6, 'PST': -8, 'PDT': -7,
'CET': 1, 'CEST': 2,
};
static final _rfc822 = RegExp(
r'^(?:\w{3},\s*)?(\d{1,2})\s+(\w{3})\w*\s+(\d{2,4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([+-]\d{4}|[A-Za-z]+)?',
);
/// Parses ISO 8601 (Atom) and RFC 822 (RSS) dates. Returns UTC.
static DateTime? parseDate(String raw) {
final s = raw.trim();
if (s.isEmpty) return null;
final iso = DateTime.tryParse(s);
if (iso != null) return iso.toUtc();
final m = _rfc822.firstMatch(s);
if (m == null) return null;
final month = _months[m.group(2)!.toLowerCase()];
if (month == null) return null;
var year = int.parse(m.group(3)!);
if (year < 100) year += 2000;
final utc = DateTime.utc(
year,
month,
int.parse(m.group(1)!),
int.parse(m.group(4)!),
int.parse(m.group(5)!),
int.parse(m.group(6) ?? '0'),
);
final zone = m.group(7);
var offset = Duration.zero;
if (zone != null && (zone.startsWith('+') || zone.startsWith('-'))) {
final sign = zone.startsWith('-') ? -1 : 1;
offset = Duration(
hours: int.parse(zone.substring(1, 3)),
minutes: int.parse(zone.substring(3, 5)),
) * sign;
} else if (zone != null) {
offset = Duration(hours: _zones[zone.toUpperCase()] ?? 0);
}
return utc.subtract(offset);
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../models/article.dart';
import '../models/news_source.dart';
import 'feed_parser.dart';
class FeedResult {
const FeedResult(this.articles, this.errors);
final List<Article> articles;
/// Source name -> error message for feeds that failed to load.
final Map<String, String> errors;
}
class FeedService {
FeedService({http.Client? client}) : _client = client ?? http.Client();
final http.Client _client;
static const _timeout = Duration(seconds: 20);
/// Fetches all [sources] in parallel. Failing feeds are reported in
/// [FeedResult.errors] instead of failing the whole refresh.
///
/// [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 maxAge,
String corsProxy = '',
}) async {
final errors = <String, String>{};
final results = await Future.wait(sources.map((source) async {
try {
return await fetchSource(source, corsProxy: corsProxy);
} catch (e) {
errors[source.name] = _describe(e);
return <Article>[];
}
}));
final cutoff = DateTime.now().toUtc().subtract(maxAge);
final seen = <String>{};
final articles = <Article>[
for (final list in results)
for (final a in list)
if ((a.published == null || a.published!.isAfter(cutoff)) && seen.add(a.id)) a,
];
articles.sort((a, b) {
final pa = a.published, pb = b.published;
if (pa == null && pb == null) return 0;
if (pa == null) return 1;
if (pb == null) return -1;
return pb.compareTo(pa);
});
return FeedResult(articles, errors);
}
Future<List<Article>> fetchSource(NewsSource source, {String corsProxy = ''}) async {
final url = kIsWeb && corsProxy.isNotEmpty
? '$corsProxy${Uri.encodeComponent(source.feedUrl)}'
: source.feedUrl;
final response = await _client.get(
Uri.parse(url),
headers: {
'Accept': 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*',
if (!kIsWeb) 'User-Agent': 'DailyTechNews/0.1 (Flutter RSS reader)',
},
).timeout(_timeout);
if (response.statusCode != 200) {
throw Exception('HTTP ${response.statusCode}');
}
return FeedParser.parse(decodeBody(response.bodyBytes), source);
}
/// Decodes feed bytes honoring the XML declaration's encoding; defaults to UTF-8.
static String decodeBody(List<int> bytes) {
final head = latin1.decode(bytes.take(200).toList()).toLowerCase();
final match = RegExp(r'''encoding=["']([\w-]+)["']''').firstMatch(head);
final encoding = match?.group(1);
if (encoding == 'iso-8859-1' || encoding == 'latin1' || encoding == 'windows-1252') {
return latin1.decode(bytes);
}
return utf8.decode(bytes, allowMalformed: true);
}
static String _describe(Object e) {
final text = e.toString();
if (kIsWeb && text.contains('ClientException')) {
return 'Blocked by the browser (CORS). Set a CORS proxy in Settings.';
}
return text.replaceFirst('Exception: ', '');
}
}
+91
View File
@@ -0,0 +1,91 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/article.dart';
import '../models/briefing.dart';
import '../models/news_source.dart';
import 'summarizer.dart';
/// Delegates summarizing to an existing n8n workflow via a Webhook trigger.
/// The request/response contract is documented in README.md.
class N8nSummarizer implements Summarizer {
N8nSummarizer({required this.webhookUrl, http.Client? client})
: _client = client ?? http.Client();
final String webhookUrl;
final http.Client _client;
@override
String get name => 'n8n workflow';
@override
Future<String> summarizeArticle(
Article article, String text, SourceLanguage language) async {
final body = await _post({
'action': 'summarize_article',
'language': language.name,
'article': {
'title': article.title,
'url': article.link,
'source': article.source.name,
'published': article.published?.toIso8601String(),
'text': text,
},
});
if (body is Map && body['summary'] is String) return body['summary'] as String;
if (body is String && body.trim().isNotEmpty) return body.trim();
throw SummarizerException('n8n response has no "summary" field.');
}
@override
Future<DailyBriefing> createBriefing(
List<Article> articles, SourceLanguage language) async {
final input = BriefingInput(articles);
final body = await _post({
'action': 'daily_briefing',
'language': language.name,
'articles': input.toJsonList(),
});
if (body is Map<String, dynamic> && body['topics'] is List) {
return input.toBriefing(body, name);
}
// Accept a plain text digest (e.g. the message the workflow posts to Discord).
final text = body is Map ? body['text'] ?? body['summary'] : body;
if (text is String && text.trim().isNotEmpty) {
return DailyBriefing(
createdAt: DateTime.now(),
intro: text.trim(),
topics: const [],
generatedBy: name,
);
}
throw SummarizerException('n8n response has neither "topics" nor "text".');
}
Future<Object?> _post(Map<String, dynamic> payload) async {
final http.Response response;
try {
response = await _client
.post(
Uri.parse(webhookUrl),
headers: {'content-type': 'application/json'},
body: jsonEncode(payload),
)
.timeout(const Duration(minutes: 3));
} on Exception catch (e) {
throw SummarizerException('Could not reach the n8n webhook: $e');
}
if (response.statusCode != 200) {
throw SummarizerException('n8n webhook returned HTTP ${response.statusCode}.');
}
final text = utf8.decode(response.bodyBytes);
try {
final json = jsonDecode(text);
// "Respond to Webhook" with "All Incoming Items" wraps results in a list.
return json is List && json.isNotEmpty ? json.first : json;
} on FormatException {
return text;
}
}
}
+105
View File
@@ -0,0 +1,105 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/article.dart';
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+)").
/// 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.
class PaywallService {
PaywallService(this._storage, {http.Client? client})
: _client = client ?? http.Client(),
_checks = _storage.loadPaywallChecks();
final Storage _storage;
final http.Client _client;
/// Article URL -> (paid, checked at).
final Map<String, ({bool paid, DateTime checkedAt})> _checks;
static final paidTitle = RegExp(
r'^\s*(heise\+|\(g\+\)|SPIEGEL\+|Z\+|t3n Pro\b|c.t\+)',
caseSensitive: false,
);
static final sponsoredTitle = RegExp(
r'^\s*(Anzeige|Sponsored|Advertorial|Werbung|Promotion)\s*[:|]',
caseSensitive: false,
);
static final _marker =
RegExp(r'''"isAccessibleForFree"\s*:\s*"?(true|false)"?''', caseSensitive: false);
/// Stop reading a page after this much without finding the marker.
static const _maxBytes = 1500000;
static const _concurrency = 6;
static const _keepFor = Duration(days: 14);
bool isPaid(Article a) => paidTitle.hasMatch(a.title) || (_checks[a.link]?.paid ?? false);
bool isSponsored(Article a) => sponsoredTitle.hasMatch(a.title);
/// Checks articles from page-checked sources that haven't been seen yet.
/// Returns true if any new paid article was found.
Future<bool> checkPages(Iterable<Article> articles) async {
final queue = articles
.where((a) =>
a.source.checkPaywallOnPage &&
!_checks.containsKey(a.link) &&
!paidTitle.hasMatch(a.title))
.toList();
if (queue.isEmpty) return false;
var foundPaid = false;
Future<void> worker() async {
while (queue.isNotEmpty) {
final article = queue.removeLast();
final paid = await _fetchIsPaid(article.link);
if (paid == null) continue; // network error: try again next refresh
_checks[article.link] = (paid: paid, checkedAt: DateTime.now());
foundPaid |= paid;
}
}
await Future.wait(List.generate(_concurrency, (_) => worker()));
final cutoff = DateTime.now().subtract(_keepFor);
_checks.removeWhere((_, v) => v.checkedAt.isBefore(cutoff));
await _storage.savePaywallChecks(_checks);
return foundPaid;
}
/// Streams the page and stops as soon as the schema.org marker shows up.
/// Returns null on network errors, false if the page has no marker.
Future<bool?> _fetchIsPaid(String url) async {
try {
final request = http.Request('GET', Uri.parse(url))
..headers['User-Agent'] = 'Mozilla/5.0 (DailyTechNews)';
final response = await _client.send(request).timeout(const Duration(seconds: 15));
if (response.statusCode != 200) {
await response.stream.listen(null).cancel();
return null;
}
var tail = '';
var read = 0;
await for (final chunk in response.stream.timeout(const Duration(seconds: 15))) {
read += chunk.length;
// The marker is ASCII, so latin1 avoids errors on split UTF-8 sequences.
// Keep a little overlap in case the marker spans two chunks.
final text = tail + latin1.decode(chunk);
final match = _marker.firstMatch(text);
if (match != null) return match.group(1)!.toLowerCase() == 'false';
if (read > _maxBytes) break;
tail = text.length > 64 ? text.substring(text.length - 64) : text;
}
return false;
} catch (_) {
return null;
}
}
}
+165
View File
@@ -0,0 +1,165 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart' show ThemeMode;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/app_settings.dart';
import '../models/briefing.dart';
import '../models/news_source.dart';
/// Persists settings, source selection and the last briefing.
/// The Claude API key goes to the platform keystore, everything else to
/// shared preferences.
class Storage {
Storage(this._prefs);
static Future<Storage> open() async => Storage(await SharedPreferences.getInstance());
final SharedPreferences _prefs;
final _secure = const FlutterSecureStorage();
static const _apiKey = 'claude_api_key';
Future<AppSettings> loadSettings() async {
String apiKey = '';
try {
apiKey = await _secure.read(key: _apiKey) ?? '';
} catch (_) {
// Keystore unavailable (e.g. some web/desktop setups) -> user re-enters it.
}
return AppSettings(
aiProvider: AiProvider.values.byName(_prefs.getString('ai_provider') ?? 'none'),
claudeApiKey: apiKey,
claudeModel: _prefs.getString('claude_model') ?? 'claude-opus-5-5',
n8nWebhookUrl: _prefs.getString('n8n_webhook_url') ?? '',
summaryLanguage:
SourceLanguage.values.byName(_prefs.getString('summary_language') ?? 'de'),
maxAgeHours: _prefs.getInt('max_age_hours') ?? 24,
corsProxy: _prefs.getString('cors_proxy') ?? '',
hidePaid: _prefs.getBool('hide_paid') ?? true,
hideSponsored: _prefs.getBool('hide_sponsored') ?? true,
blockAds: _prefs.getBool('block_ads') ?? true,
hideCookieBanners: _prefs.getBool('hide_cookie_banners') ?? true,
acceptConsentWalls: _prefs.getBool('accept_consent_walls') ?? true,
themeMode: ThemeMode.values.asNameMap()[_prefs.getString('theme_mode')] ?? ThemeMode.system,
);
}
Future<void> saveSettings(AppSettings s) async {
await _prefs.setString('ai_provider', s.aiProvider.name);
await _prefs.setString('claude_model', s.claudeModel);
await _prefs.setString('n8n_webhook_url', s.n8nWebhookUrl);
await _prefs.setString('summary_language', s.summaryLanguage.name);
await _prefs.setInt('max_age_hours', s.maxAgeHours);
await _prefs.setString('cors_proxy', s.corsProxy);
await _prefs.setBool('hide_paid', s.hidePaid);
await _prefs.setBool('hide_sponsored', s.hideSponsored);
await _prefs.setBool('block_ads', s.blockAds);
await _prefs.setBool('hide_cookie_banners', s.hideCookieBanners);
await _prefs.setBool('accept_consent_walls', s.acceptConsentWalls);
await _prefs.setString('theme_mode', s.themeMode.name);
try {
await _secure.write(key: _apiKey, value: s.claudeApiKey);
} catch (_) {}
}
/// Null means "never configured" -> use each source's default.
Set<String>? loadEnabledSourceIds() =>
_prefs.getStringList('enabled_sources')?.toSet();
Future<void> saveEnabledSourceIds(Set<String> ids) =>
_prefs.setStringList('enabled_sources', ids.toList());
List<NewsSource> 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>))
.toList();
}
Future<void> saveCustomSources(List<NewsSource> 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.
Map<String, ({bool paid, DateTime checkedAt})> loadPaywallChecks() {
final raw = _prefs.getString('paywall_checks');
if (raw == null) return {};
try {
return (jsonDecode(raw) as Map<String, dynamic>).map((url, v) {
final entry = v as List;
return MapEntry(url, (
paid: entry[0] as bool,
checkedAt: DateTime.fromMillisecondsSinceEpoch(entry[1] as int),
));
});
} catch (_) {
return {};
}
}
Future<void> savePaywallChecks(Map<String, ({bool paid, DateTime checkedAt})> checks) =>
_prefs.setString(
'paywall_checks',
jsonEncode(checks.map(
(url, v) => MapEntry(url, [v.paid, v.checkedAt.millisecondsSinceEpoch]))),
);
/// All saved briefings, newest first. Stored as a JSON file in the app's
/// private storage (preferences on web), since the history keeps growing.
Future<List<DailyBriefing>> loadBriefingHistory() async {
List<DailyBriefing> history;
try {
String? raw;
if (kIsWeb) {
raw = _prefs.getString(_historyKey);
} else {
final file = await _historyFile();
if (await file.exists()) raw = await file.readAsString();
}
history = raw == null
? []
: (jsonDecode(raw) as List)
.map((j) => DailyBriefing.fromJson(j as Map<String, dynamic>))
.toList();
} catch (_) {
history = [];
}
// Earlier versions kept only the last briefing in preferences.
final legacy = _prefs.getString('last_briefing');
if (legacy != null) {
try {
final briefing = DailyBriefing.fromJson(jsonDecode(legacy) as Map<String, dynamic>);
if (!history.any((b) => b.createdAt == briefing.createdAt)) history.add(briefing);
await saveBriefingHistory(history);
} catch (_) {}
await _prefs.remove('last_briefing');
}
history.sort((a, b) => b.createdAt.compareTo(a.createdAt));
return history;
}
Future<void> saveBriefingHistory(List<DailyBriefing> history) async {
final json = jsonEncode(history.map((b) => b.toJson()).toList());
if (kIsWeb) {
await _prefs.setString(_historyKey, json);
return;
}
// Write to a temp file and rename, so a crash mid-write can't corrupt it.
final file = await _historyFile();
final temp = File('${file.path}.tmp');
await temp.writeAsString(json, flush: true);
await temp.rename(file.path);
}
static const _historyKey = 'briefing_history';
Future<File> _historyFile() async =>
File('${(await getApplicationSupportDirectory()).path}/briefings.json');
}
+88
View File
@@ -0,0 +1,88 @@
import '../models/article.dart';
import '../models/briefing.dart';
import '../models/news_source.dart';
class SummarizerException implements Exception {
SummarizerException(this.message);
final String message;
@override
String toString() => message;
}
/// Something that can turn articles into summaries. Implemented by the
/// direct Claude API client and by the n8n webhook bridge.
abstract class Summarizer {
String get name;
Future<String> summarizeArticle(Article article, String text, SourceLanguage language);
Future<DailyBriefing> createBriefing(List<Article> articles, SourceLanguage language);
}
/// Shared prompt material and response mapping for all summarizers.
class BriefingInput {
BriefingInput(List<Article> articles)
: byId = {for (var i = 0; i < articles.length; i++) 'a${i + 1}': articles[i]};
/// Short ids keep the prompt compact and are easy for the model to cite.
final Map<String, Article> byId;
static const excerptChars = 220;
String toPromptList() {
final buffer = StringBuffer();
byId.forEach((id, a) {
final excerpt = a.excerpt.length > excerptChars
? '${a.excerpt.substring(0, excerptChars)}…'
: a.excerpt;
buffer.writeln('[$id] (${a.source.name}) ${a.title}'
'${excerpt.isEmpty ? '' : ' — $excerpt'}');
});
return buffer.toString();
}
List<Map<String, dynamic>> toJsonList() => [
for (final e in byId.entries)
{
'id': e.key,
'title': e.value.title,
'url': e.value.link,
'source': e.value.source.name,
'language': e.value.source.language.name,
'published': e.value.published?.toIso8601String(),
'excerpt': e.value.excerpt,
}
];
/// Maps `{intro, topics: [{title, summary, article_ids}]}` onto a briefing.
DailyBriefing toBriefing(Map<String, dynamic> json, String generatedBy) {
final topics = (json['topics'] as List? ?? []).map((t) {
final topic = t as Map<String, dynamic>;
final ids = (topic['article_ids'] as List? ?? []).cast<String>();
return BriefingTopic(
title: topic['title'] as String? ?? '',
summary: topic['summary'] as String? ?? '',
sources: [
for (final id in ids)
if (byId[id] != null)
ArticleRef(
title: byId[id]!.title,
url: byId[id]!.link,
sourceName: byId[id]!.source.name,
),
],
);
}).toList();
return DailyBriefing(
createdAt: DateTime.now(),
intro: json['intro'] as String? ?? '',
topics: topics,
generatedBy: generatedBy,
);
}
}
String languageName(SourceLanguage language) =>
language == SourceLanguage.de ? 'German' : 'English';
+26
View File
@@ -0,0 +1,26 @@
/// Dark mode for the in-app "original page" view.
///
/// When the phone itself is in dark mode, Android darkens pages on its own
/// (algorithmic darkening, plus `prefers-color-scheme: dark` for sites with a
/// dark design). When only the app is set to dark, Android doesn't, so this
/// stylesheet inverts the page and flips photos and videos back.
class WebDarkMode {
static const _css = 'html{filter:invert(1) hue-rotate(180deg)!important;'
'background:#fff!important}'
'img,picture,video,canvas,iframe,svg image,[style*="background-image"]'
'{filter:invert(1) hue-rotate(180deg)!important}';
static const script = '''
(function () {
function apply() {
if (document.getElementById('tagesbyte-dark')) return;
var style = document.createElement('style');
style.id = 'tagesbyte-dark';
style.textContent = '$_css';
(document.head || document.documentElement).appendChild(style);
}
apply();
document.addEventListener('DOMContentLoaded', apply);
})();
''';
}
+267
View File
@@ -0,0 +1,267 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../data/default_sources.dart';
import '../models/app_settings.dart';
import '../models/article.dart';
import '../models/briefing.dart';
import '../models/news_source.dart';
import '../services/article_extractor.dart';
import '../services/claude_summarizer.dart';
import '../services/consent_service.dart';
import '../services/feed_service.dart';
import '../services/n8n_summarizer.dart';
import '../services/paywall_service.dart';
import '../services/storage.dart';
import '../services/summarizer.dart';
/// The result of an on-demand article summary.
class SummaryState {
const SummaryState.loading() : text = null, error = null;
const SummaryState.done(this.text) : error = null;
const SummaryState.failed(this.error) : text = null;
final String? text;
final String? error;
bool get isLoading => text == null && error == null;
}
class AppState extends ChangeNotifier {
AppState(this._storage, {FeedService? feedService, ArticleExtractor? extractor})
: _feeds = feedService ?? FeedService(),
consent = ConsentService(),
paywall = PaywallService(_storage) {
_extractor = extractor ?? ArticleExtractor(consent: consent);
}
final Storage _storage;
final FeedService _feeds;
late final ArticleExtractor _extractor;
final PaywallService paywall;
/// Consent cookies for sites with consent walls, shared with the web view.
final ConsentService consent;
AppSettings settings = const AppSettings();
List<NewsSource> customSources = [];
Set<String> enabledSourceIds = {};
/// Everything the feeds returned; see [visibleArticles] for what is shown.
List<Article> articles = [];
Map<String, String> feedErrors = {};
bool loading = false;
DateTime? lastRefresh;
/// Saved briefings, newest first.
List<DailyBriefing> briefings = [];
DailyBriefing? get briefing => briefings.firstOrNull;
bool briefingLoading = false;
String? briefingError;
final Map<String, SummaryState> summaries = {};
/// Reader content per article id, shared by the reader view and the AI.
final Map<String, Future<ArticleContent>> _content = {};
bool _isHidden(Article a) =>
(settings.hidePaid && paywall.isPaid(a)) ||
(settings.hideSponsored && paywall.isSponsored(a));
/// Articles after the paid/sponsored filters. The feed and the briefing use these.
List<Article> get visibleArticles => articles.where((a) => !_isHidden(a)).toList();
int get hiddenCount => articles.where(_isHidden).length;
List<NewsSource> get allSources => [...defaultSources, ...customSources];
List<NewsSource> get enabledSources =>
allSources.where((s) => enabledSourceIds.contains(s.id)).toList();
Future<void> init() async {
settings = await _storage.loadSettings();
customSources = _storage.loadCustomSources();
enabledSourceIds = _storage.loadEnabledSourceIds() ??
defaultSources.where((s) => s.enabledByDefault).map((s) => s.id).toSet();
briefings = await _storage.loadBriefingHistory();
notifyListeners();
await refresh();
}
Future<void> refresh() async {
if (loading) return;
loading = true;
notifyListeners();
final result = await _feeds.fetchAll(
enabledSources,
maxAge: Duration(hours: settings.maxAgeHours),
corsProxy: settings.corsProxy,
);
articles = result.articles;
feedErrors = result.errors;
lastRefresh = DateTime.now();
loading = false;
notifyListeners();
// Runs in the background; hides newly found paid articles as they turn up.
unawaited(paywall.checkPages(articles).then((foundPaid) {
if (foundPaid) notifyListeners();
}));
}
// --- Sources -------------------------------------------------------------
Future<void> setSourceEnabled(NewsSource source, bool enabled) async {
enabled ? enabledSourceIds.add(source.id) : enabledSourceIds.remove(source.id);
notifyListeners();
await _storage.saveEnabledSourceIds(enabledSourceIds);
}
/// Validates the feed by fetching it before adding. Returns an error message
/// or null on success.
Future<String?> addCustomSource({
required String name,
required String feedUrl,
required SourceLanguage language,
}) async {
final uri = Uri.tryParse(feedUrl);
final source = NewsSource(
id: 'custom-${DateTime.now().millisecondsSinceEpoch}',
name: name,
feedUrl: feedUrl,
homepage: uri == null ? '' : '${uri.scheme}://${uri.host}',
language: language,
isCustom: true,
);
try {
final items = await _feeds.fetchSource(source, corsProxy: settings.corsProxy);
if (items.isEmpty) return 'The feed loaded but contains no articles.';
} catch (e) {
return 'Could not load feed: $e';
}
customSources = [...customSources, source];
enabledSourceIds.add(source.id);
await _storage.saveCustomSources(customSources);
await _storage.saveEnabledSourceIds(enabledSourceIds);
notifyListeners();
return null;
}
Future<void> removeCustomSource(NewsSource source) async {
customSources = customSources.where((s) => s.id != source.id).toList();
enabledSourceIds.remove(source.id);
await _storage.saveCustomSources(customSources);
await _storage.saveEnabledSourceIds(enabledSourceIds);
notifyListeners();
}
// --- Settings ------------------------------------------------------------
Future<void> updateSettings(AppSettings updated) async {
final previous = settings;
final windowChanged = updated.maxAgeHours != settings.maxAgeHours ||
updated.corsProxy != settings.corsProxy;
final aiChanged = updated.aiProvider != settings.aiProvider ||
updated.claudeModel != settings.claudeModel ||
updated.summaryLanguage != settings.summaryLanguage;
settings = updated;
if (aiChanged) summaries.clear();
// Cached reader content may be a consent-wall teaser; reload it.
if (updated.acceptConsentWalls != previous.acceptConsentWalls) _content.clear();
notifyListeners();
await _storage.saveSettings(updated);
if (windowChanged) await refresh();
}
// --- Reading -------------------------------------------------------------
Future<ArticleContent> loadArticle(Article article) => _content.putIfAbsent(
article.id,
() => _extractor.load(article,
corsProxy: settings.corsProxy, acceptConsentWalls: settings.acceptConsentWalls));
/// Finds a loaded article by URL, or builds a minimal one (e.g. for links in
/// an older briefing whose articles have dropped out of the feed).
Article articleFor({required String url, required String title, required String sourceName}) {
for (final a in articles) {
if (a.link == url) return a;
}
final source = allSources.firstWhere(
(s) => s.name == sourceName,
orElse: () => NewsSource(
id: sourceName,
name: sourceName,
feedUrl: '',
homepage: '',
language: SourceLanguage.en,
),
);
return Article(title: title, link: url, source: source);
}
// --- AI ------------------------------------------------------------------
Summarizer? get _summarizer => switch (settings.aiProvider) {
_ when !settings.aiConfigured => null,
AiProvider.claude => ClaudeSummarizer(
apiKey: settings.claudeApiKey, model: settings.claudeModel),
AiProvider.n8n => N8nSummarizer(webhookUrl: settings.n8nWebhookUrl),
AiProvider.none => null,
};
Future<void> summarize(Article article) async {
final summarizer = _summarizer;
if (summarizer == null) return;
summaries[article.id] = const SummaryState.loading();
notifyListeners();
try {
final content = await loadArticle(article);
final text = content.plainText.length > article.excerpt.length
? content.plainText
: article.excerpt;
final summary =
await summarizer.summarizeArticle(article, text, settings.summaryLanguage);
summaries[article.id] = SummaryState.done(summary);
} catch (e) {
summaries[article.id] = SummaryState.failed(e.toString());
}
notifyListeners();
}
Future<void> deleteBriefing(DailyBriefing briefing) async {
briefings = briefings.where((b) => !identical(b, briefing)).toList();
notifyListeners();
await _storage.saveBriefingHistory(briefings);
}
/// Puts a deleted briefing back (undo).
Future<void> restoreBriefing(DailyBriefing briefing) async {
briefings = [...briefings, briefing]
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
notifyListeners();
await _storage.saveBriefingHistory(briefings);
}
Future<void> generateBriefing() async {
final summarizer = _summarizer;
if (summarizer == null || briefingLoading) return;
final articles = visibleArticles;
if (articles.isEmpty) {
briefingError = 'No articles loaded yet. Pull to refresh the feed first.';
notifyListeners();
return;
}
briefingLoading = true;
briefingError = null;
notifyListeners();
try {
final created = await summarizer.createBriefing(articles, settings.summaryLanguage);
briefings = [created, ...briefings];
await _storage.saveBriefingHistory(briefings);
} catch (e) {
briefingError = e.toString();
}
briefingLoading = false;
notifyListeners();
}
}
+546
View File
@@ -0,0 +1,546 @@
import 'dart:collection';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../models/article.dart';
import '../services/article_extractor.dart';
import '../services/consent_service.dart';
import '../services/content_blocking.dart';
import '../services/web_dark_mode.dart';
import '../state/app_state.dart';
import 'common.dart';
Future<void> openArticle(BuildContext context, AppState state, Article article) {
// Otherwise the previous screen restores its focus (and keyboard) on return.
FocusManager.instance.primaryFocus?.unfocus();
return Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => ArticleScreen(state: state, article: article),
));
}
enum _Mode { reader, original }
/// Shows an article inside the app: a clean reader view by default, or the
/// original page in a WebView.
class ArticleScreen extends StatefulWidget {
const ArticleScreen({super.key, required this.state, required this.article});
final AppState state;
final Article article;
@override
State<ArticleScreen> createState() => _ArticleScreenState();
}
class _ArticleScreenState extends State<ArticleScreen> {
late final Future<ArticleContent> _content = widget.state.loadArticle(widget.article);
_Mode _mode = _Mode.reader;
bool _webStarted = false;
InAppWebViewController? _web;
int _webProgress = 0;
/// Only Android, iOS and macOS are set up; Windows would need WebView2 tooling.
static bool get webViewSupported =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS);
/// Set when the user chooses "Open anyway" on a consent wall.
bool _allowConsentWall = false;
bool _stoppedAtWall = false;
bool _triedAccepting = false;
Future<void> _showOriginal() async {
if (!webViewSupported) {
openUrl(context, widget.article.link);
return;
}
// Reader view usually accepted the wall already; give the page its cookies.
await _syncConsentCookies();
if (_stoppedAtWall) {
_stoppedAtWall = false;
_web?.loadUrl(urlRequest: URLRequest(url: WebUri(widget.article.link)));
}
if (!mounted) return;
setState(() {
_webStarted = true;
_mode = _Mode.original;
});
}
Future<void> _syncConsentCookies() async {
final url = Uri.parse(widget.article.link);
final cookies = widget.state.consent.cookiesFor(url);
// Consent from Golem's simple button is valid for a day.
final expires = DateTime.now().add(const Duration(days: 1)).millisecondsSinceEpoch;
for (final cookie in cookies.entries) {
await CookieManager.instance().setCookie(
url: WebUri(widget.article.link),
name: cookie.key,
value: cookie.value,
domain: '.${ConsentService.domainOf(url)}',
isSecure: true,
expiresDate: expires,
);
}
}
void _openAnyway() {
_allowConsentWall = true;
_showOriginal();
}
Future<void> _checkConsentWall(InAppWebViewController controller, WebUri? url) async {
if (url == null ||
_stoppedAtWall ||
_allowConsentWall ||
!ContentBlocking.consentWall.hasMatch(url.toString())) {
return;
}
final settings = widget.state.settings;
final consent = widget.state.consent;
if (settings.acceptConsentWalls && !_triedAccepting && consent.canAccept(url)) {
_triedAccepting = true;
await controller.stopLoading();
if (await consent.accept(url)) {
await _syncConsentCookies();
await controller.loadUrl(urlRequest: URLRequest(url: WebUri(widget.article.link)));
return;
}
}
if (!settings.hideCookieBanners) return;
await controller.stopLoading();
_stoppedAtWall = true;
if (!mounted) return;
setState(() => _mode = _Mode.reader);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
duration: const Duration(seconds: 8),
content: Text('${widget.article.source.name} only opens its website after you accept '
'tracking or subscribe. Showing reader view instead.'),
action: SnackBarAction(
label: 'Open anyway',
onPressed: _openAnyway,
),
));
}
Widget _buildWebView() {
final settings = widget.state.settings;
final script = ContentBlocking.cosmeticScript(
ads: settings.blockAds, cookies: settings.hideCookieBanners);
final appDark = Theme.of(context).brightness == Brightness.dark;
final systemDark = MediaQuery.platformBrightnessOf(context) == Brightness.dark;
return InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(widget.article.link)),
initialSettings: InAppWebViewSettings(
contentBlockers: ContentBlocking.blockers(
ads: settings.blockAds, cookies: settings.hideCookieBanners),
// Only takes effect when the phone is in dark mode; see WebDarkMode.
algorithmicDarkeningAllowed: appDark,
),
initialUserScripts: UnmodifiableListView([
if (script != null)
UserScript(source: script, injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START),
if (appDark && !systemDark)
UserScript(
source: WebDarkMode.script,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START),
]),
onWebViewCreated: (controller) => _web = controller,
// Redirects don't reliably fire every callback, so check them all.
onLoadStart: _checkConsentWall,
onLoadStop: _checkConsentWall,
onUpdateVisitedHistory: (controller, url, _) => _checkConsentWall(controller, url),
onProgressChanged: (_, progress) {
if (mounted) setState(() => _webProgress = progress);
},
);
}
Future<void> _handleBack() async {
// In the original page, back walks the page's own history first.
final web = _web;
if (_mode == _Mode.original && web != null && await web.canGoBack()) {
await web.goBack();
} else if (mounted) {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final article = widget.article;
final original = _mode == _Mode.original;
return PopScope(
canPop: !original,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _handleBack();
},
child: Scaffold(
appBar: AppBar(
title: Text(article.source.name),
actions: [
if (webViewSupported)
IconButton(
tooltip: original ? 'Reader view' : 'Original page',
icon: Icon(original ? Icons.article_outlined : Icons.public),
onPressed: original
? () => setState(() => _mode = _Mode.reader)
: _showOriginal,
),
PopupMenuButton<String>(
onSelected: (_) => openUrl(context, article.link),
itemBuilder: (_) => const [
PopupMenuItem(value: 'browser', child: Text('Open in browser')),
],
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(2),
child: original && _webProgress < 100
? LinearProgressIndicator(value: _webProgress / 100, minHeight: 2)
: const SizedBox(height: 2),
),
),
// IndexedStack keeps the reader's scroll position and the loaded page
// when switching back and forth.
body: IndexedStack(
index: original ? 1 : 0,
children: [
_ReaderView(
state: widget.state,
article: article,
content: _content,
onShowOriginal: _showOriginal,
onOpenAnyway: _openAnyway,
),
if (_webStarted) _buildWebView() else const SizedBox(),
],
),
floatingActionButton: original && widget.state.settings.aiConfigured
? FloatingActionButton.extended(
onPressed: () => showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (_) => SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
child: AiSummaryCard(state: widget.state, article: article),
),
),
),
icon: const Icon(Icons.auto_awesome),
label: const Text('AI summary'),
)
: null,
),
);
}
}
class _ReaderView extends StatelessWidget {
const _ReaderView({
required this.state,
required this.article,
required this.content,
required this.onShowOriginal,
required this.onOpenAnyway,
});
final AppState state;
final Article article;
final Future<ArticleContent> content;
final VoidCallback onShowOriginal;
/// Opens the original page even if it is a consent wall.
final VoidCallback onOpenAnyway;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
final meta = [
if (article.published != null) formatDateTime(article.published!),
if (article.author != null) article.author!,
].join(' · ');
return SelectionArea(
child: FutureBuilder<ArticleContent>(
future: content,
builder: (context, snapshot) {
final loaded = snapshot.data;
var blocks = loaded?.blocks ?? const <ReaderBlock>[];
// The page's own hero image duplicates the feed image shown on top.
if (article.imageUrl != null) {
blocks = blocks.skipWhile((b) => b.type == BlockType.image).toList();
}
final isFull = loaded != null &&
loaded.plainText.length >= max(400, article.excerpt.length * 2);
// 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 &&
(firstText == null ||
!firstText.text.startsWith(
article.excerpt.substring(0, min(40, article.excerpt.length))));
return ListView(
padding: const EdgeInsets.only(bottom: 48),
children: [
if (article.imageUrl != null)
Image.network(
article.imageUrl!,
fit: BoxFit.cover,
webHtmlElementStrategy: WebHtmlElementStrategy.prefer,
// Take up space only once the image has loaded, so a broken
// image leaves no gap.
frameBuilder: (_, child, frame, __) => frame == null
? const SizedBox.shrink()
: AspectRatio(aspectRatio: 16 / 9, child: child),
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (meta.isNotEmpty)
Text(meta,
style: theme.textTheme.labelMedium
?.copyWith(color: theme.colorScheme.primary)),
const SizedBox(height: 8),
Text(article.title, style: theme.textTheme.headlineSmall),
if (showLead) ...[
const SizedBox(height: 12),
Text(article.excerpt,
style: theme.textTheme.titleMedium
?.copyWith(color: muted, height: 1.5)),
],
const SizedBox(height: 20),
AiSummaryCard(state: state, article: article),
const SizedBox(height: 20),
if (loaded == null)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: CircularProgressIndicator()),
)
else if (!isFull)
_TeaserOnlyNotice(
sourceName: article.source.name,
consentWall: loaded.consentWall,
onShowOriginal: loaded.consentWall ? onOpenAnyway : onShowOriginal,
),
if (isFull)
for (final block in blocks)
if (block.text != article.imageUrl) _BlockView(block: block),
if (loaded != null) ...[
const SizedBox(height: 24),
const Divider(),
TextButton.icon(
onPressed: onShowOriginal,
icon: const Icon(Icons.public, size: 18),
label: Text(
'Original article on ${Uri.parse(article.link).host.replaceFirst('www.', '')}'),
),
],
],
),
),
],
);
},
),
);
}
}
class _BlockView extends StatelessWidget {
const _BlockView({required this.block});
final ReaderBlock block;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final body = theme.textTheme.bodyLarge?.copyWith(height: 1.65);
return switch (block.type) {
BlockType.heading => Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: Text(block.text,
style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600)),
),
BlockType.paragraph => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(block.text, style: body),
),
BlockType.listItem => Padding(
padding: const EdgeInsets.only(bottom: 8, left: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('• ', style: body),
Expanded(child: Text(block.text, style: body)),
],
),
),
BlockType.quote => Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.only(left: 16),
decoration: BoxDecoration(
border: Border(left: BorderSide(color: theme.colorScheme.primary, width: 3)),
),
child: Text(block.text, style: body?.copyWith(fontStyle: FontStyle.italic)),
),
BlockType.code => Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(12),
width: double.infinity,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(block.text,
style: const TextStyle(fontFamily: 'monospace', fontSize: 13)),
),
),
BlockType.image => Padding(
padding: const EdgeInsets.only(bottom: 8, top: 4),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
block.text,
width: double.infinity,
fit: BoxFit.fitWidth,
webHtmlElementStrategy: WebHtmlElementStrategy.prefer,
errorBuilder: (_, __, ___) => const SizedBox(),
),
),
),
BlockType.caption => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(block.text,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant)),
),
};
}
}
class _TeaserOnlyNotice extends StatelessWidget {
const _TeaserOnlyNotice({
required this.sourceName,
required this.consentWall,
required this.onShowOriginal,
});
final String sourceName;
final bool consentWall;
final VoidCallback onShowOriginal;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
consentWall
? '$sourceName only shows its articles after you accept tracking or '
'subscribe, so only the teaser is available here.'
: "The full text couldn't be loaded in reader view. It may be behind a "
'paywall or the site blocks it.',
style: TextStyle(color: theme.colorScheme.onSecondaryContainer),
),
const SizedBox(height: 8),
FilledButton.tonalIcon(
onPressed: onShowOriginal,
icon: const Icon(Icons.public),
label: Text(consentWall ? 'Open website anyway' : 'Show original page'),
),
],
),
);
}
}
/// The "Summarize with AI" button and its result.
class AiSummaryCard extends StatelessWidget {
const AiSummaryCard({super.key, required this.state, required this.article});
final AppState state;
final Article article;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: state,
builder: (context, _) {
final theme = Theme.of(context);
final summary = state.summaries[article.id];
if (!state.settings.aiConfigured) {
return Text(
'Tip: set up Claude or your n8n workflow in Settings to get AI summaries.',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
);
}
if (summary == null) {
return FilledButton.tonalIcon(
onPressed: () => state.summarize(article),
icon: const Icon(Icons.auto_awesome),
label: const Text('Summarize with AI'),
);
}
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.auto_awesome, size: 18, color: theme.colorScheme.tertiary),
const SizedBox(width: 8),
Text('AI summary', style: theme.textTheme.titleSmall),
],
),
const SizedBox(height: 12),
if (summary.isLoading)
const Row(children: [
SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)),
SizedBox(width: 12),
Text('Reading the article…'),
])
else if (summary.error != null) ...[
Text(summary.error!, style: TextStyle(color: theme.colorScheme.error)),
TextButton(
onPressed: () => state.summarize(article), child: const Text('Try again')),
] else
Text(summary.text!,
style: theme.textTheme.bodyMedium?.copyWith(height: 1.5)),
],
),
);
},
);
}
}
+191
View File
@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import '../models/briefing.dart';
import '../state/app_state.dart';
import 'briefing_screen.dart';
import 'common.dart';
/// All saved briefings, newest first, grouped by month.
class BriefingHistoryScreen extends StatelessWidget {
const BriefingHistoryScreen({super.key, required this.state});
final AppState state;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: state,
builder: (context, _) {
final briefings = state.briefings;
return Scaffold(
appBar: AppBar(
title: const Text('Briefing history'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(24),
child: Container(
alignment: AlignmentDirectional.centerStart,
padding: const EdgeInsetsDirectional.only(start: 16, bottom: 8),
child: Text(
'${briefings.length} saved · swipe left to delete',
style: Theme.of(context).textTheme.bodySmall,
),
),
),
),
body: briefings.isEmpty
? const EmptyState(
icon: Icons.history,
title: 'No briefings yet',
message: 'Every briefing you generate is saved here.',
)
: ListView(
padding: const EdgeInsets.only(bottom: 24),
children: [
for (final (i, briefing) in briefings.indexed) ...[
if (i == 0 || !_sameMonth(briefings[i - 1].createdAt, briefing.createdAt))
_MonthHeader(date: briefing.createdAt),
_HistoryTile(state: state, briefing: briefing, isLatest: i == 0),
],
],
),
);
},
);
}
static bool _sameMonth(DateTime a, DateTime b) {
final la = a.toLocal(), lb = b.toLocal();
return la.year == lb.year && la.month == lb.month;
}
}
class _MonthHeader extends StatelessWidget {
const _MonthHeader({required this.date});
final DateTime date;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final d = date.toLocal();
return Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
child: Text('${monthNames[d.month - 1]} ${d.year}',
style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.primary)),
);
}
}
class _HistoryTile extends StatelessWidget {
const _HistoryTile({required this.state, required this.briefing, required this.isLatest});
final AppState state;
final DailyBriefing briefing;
final bool isLatest;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final d = briefing.createdAt.toLocal();
final time = '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
return Dismissible(
key: ObjectKey(briefing),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 24),
color: theme.colorScheme.errorContainer,
child: Icon(Icons.delete_outline, color: theme.colorScheme.onErrorContainer),
),
onDismissed: (_) {
state.deleteBriefing(briefing);
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(SnackBar(
content: Text('Briefing from ${formatDateTime(briefing.createdAt)} deleted'),
action: SnackBarAction(
label: 'Undo',
onPressed: () => state.restoreBriefing(briefing),
),
));
},
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: _DateBadge(date: d),
title: Text(
'${weekdayNames[d.weekday - 1]} · $time${isLatest ? ' · latest' : ''}',
style: theme.textTheme.titleSmall,
),
subtitle: Text(
briefing.intro.isNotEmpty
? briefing.intro
: briefing.topics.map((t) => t.title).join(' · '),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: briefing.topics.isEmpty
? null
: Text(
'${briefing.topics.length}\n${briefing.topics.length == 1 ? 'topic' : 'topics'}',
textAlign: TextAlign.center,
style: theme.textTheme.labelSmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant)),
onTap: () => Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => BriefingDetailScreen(state: state, briefing: briefing),
)),
),
);
}
}
class _DateBadge extends StatelessWidget {
const _DateBadge({required this.date});
final DateTime date;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: 48,
padding: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('${date.day}',
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600)),
Text(monthNames[date.month - 1].substring(0, 3),
style: theme.textTheme.labelSmall
?.copyWith(color: theme.colorScheme.onPrimaryContainer)),
],
),
);
}
}
/// One saved briefing, read-only.
class BriefingDetailScreen extends StatelessWidget {
const BriefingDetailScreen({super.key, required this.state, required this.briefing});
final AppState state;
final DailyBriefing briefing;
@override
Widget build(BuildContext context) {
final d = briefing.createdAt.toLocal();
return Scaffold(
appBar: AppBar(
title: Text(
'${weekdayNames[d.weekday - 1]}, ${d.day} ${monthNames[d.month - 1]} ${d.year}')),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: briefingContent(context, state, briefing),
),
);
}
}
+211
View File
@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import '../models/briefing.dart';
import '../state/app_state.dart';
import 'article_screen.dart';
import 'briefing_history_screen.dart';
import 'common.dart';
class BriefingScreen extends StatelessWidget {
const BriefingScreen({super.key, required this.state, required this.openSettings});
final AppState state;
final VoidCallback openSettings;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: state,
builder: (context, _) {
final briefing = state.briefing;
final canGenerate =
state.settings.aiConfigured && !state.briefingLoading && !state.loading;
return Scaffold(
appBar: AppBar(
title: const Text('Daily Briefing'),
actions: [
if (state.briefings.isNotEmpty)
IconButton(
tooltip: 'History',
icon: const Icon(Icons.history),
onPressed: () => Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => BriefingHistoryScreen(state: state),
)),
),
if (briefing != null && state.settings.aiConfigured)
IconButton(
tooltip: 'Regenerate',
onPressed: canGenerate ? state.generateBriefing : null,
icon: const Icon(Icons.refresh),
),
],
),
body: _body(context, briefing, canGenerate),
);
},
);
}
Widget _body(BuildContext context, DailyBriefing? briefing, bool canGenerate) {
if (!state.settings.aiConfigured) {
return EmptyState(
icon: Icons.auto_awesome,
title: 'AI is not set up yet',
message: 'Add a Claude API key or your n8n webhook to get a daily briefing '
'of the most important stories.',
action: FilledButton(onPressed: openSettings, child: const Text('Open Settings')),
);
}
if (state.briefingLoading) {
return EmptyState(
icon: Icons.hourglass_top,
title: 'Reading ${state.visibleArticles.length} headlines…',
message: 'This usually takes 10-60 seconds.',
action: const SizedBox(width: 160, child: LinearProgressIndicator()),
);
}
final generateButton = FilledButton.icon(
onPressed: canGenerate ? state.generateBriefing : null,
icon: const Icon(Icons.auto_awesome),
label: Text(state.loading
? 'Waiting for feeds…'
: 'Generate from ${state.visibleArticles.length} articles'),
);
if (briefing == null) {
return EmptyState(
icon: Icons.summarize_outlined,
title: 'No briefing yet',
message: state.briefingError ??
'Let the AI pick and summarize the most important stories of the day.',
action: generateButton,
);
}
final theme = Theme.of(context);
final outdated = !isSameDay(briefing.createdAt, DateTime.now());
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
if (state.briefingError != null)
_Notice(text: state.briefingError!, color: theme.colorScheme.errorContainer),
if (outdated)
_Notice(
text: 'This briefing is from ${formatDateTime(briefing.createdAt)}.',
color: theme.colorScheme.secondaryContainer,
action: TextButton(
onPressed: canGenerate ? state.generateBriefing : null,
child: const Text('Update')),
),
...briefingContent(context, state, briefing),
],
);
}
}
/// Date line, intro and topic cards of a briefing. Shared by today's briefing
/// and the history.
List<Widget> briefingContent(BuildContext context, AppState state, DailyBriefing briefing) {
final theme = Theme.of(context);
return [
Text(
'${formatDateTime(briefing.createdAt)} · ${briefing.generatedBy}',
style: theme.textTheme.labelMedium?.copyWith(color: theme.colorScheme.primary),
),
const SizedBox(height: 8),
Text(briefing.intro, style: theme.textTheme.titleMedium?.copyWith(height: 1.4)),
const SizedBox(height: 16),
for (final (i, topic) in briefing.topics.indexed) ...[
_TopicCard(state: state, number: i + 1, topic: topic),
const SizedBox(height: 12),
],
];
}
class _TopicCard extends StatelessWidget {
const _TopicCard({required this.state, required this.number, required this.topic});
final AppState state;
final int number;
final BriefingTopic topic;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 14,
backgroundColor: theme.colorScheme.primaryContainer,
child: Text('$number',
style: theme.textTheme.labelLarge
?.copyWith(color: theme.colorScheme.onPrimaryContainer)),
),
const SizedBox(width: 12),
Expanded(child: Text(topic.title, style: theme.textTheme.titleMedium)),
],
),
const SizedBox(height: 10),
Text(topic.summary, style: theme.textTheme.bodyMedium?.copyWith(height: 1.5)),
if (topic.sources.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final ref in topic.sources)
Tooltip(
message: ref.title,
child: ActionChip(
avatar: const Icon(Icons.article_outlined, size: 16),
label: Text(ref.sourceName),
onPressed: () => openArticle(
context,
state,
state.articleFor(
url: ref.url, title: ref.title, sourceName: ref.sourceName),
),
),
),
],
),
],
],
),
),
);
}
}
class _Notice extends StatelessWidget {
const _Notice({required this.text, required this.color, this.action});
final String text;
final Color color;
final Widget? action;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(8)),
child: Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(text),
)),
if (action != null) action!,
],
),
);
}
}
+86
View File
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
/// User-facing app name. Platform launcher labels (AndroidManifest, Info.plist,
/// web/manifest.json, windows/runner) are set separately.
const appName = 'Tagesbyte';
String timeAgo(DateTime? date) {
if (date == null) return '';
final diff = DateTime.now().difference(date.toLocal());
if (diff.inMinutes < 1) return 'just now';
if (diff.inMinutes < 60) return '${diff.inMinutes} min ago';
if (diff.inHours < 24) return '${diff.inHours} h ago';
if (diff.inDays == 1) return 'yesterday';
return '${diff.inDays} days ago';
}
String formatDateTime(DateTime date) {
final d = date.toLocal();
String two(int n) => n.toString().padLeft(2, '0');
return '${two(d.day)}.${two(d.month)}.${d.year} ${two(d.hour)}:${two(d.minute)}';
}
const weekdayNames = [
'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday',
];
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
bool isSameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
Future<void> openUrl(BuildContext context, String url) async {
final ok = await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
if (!ok && context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Could not open $url')));
}
}
/// Centered icon + text + optional action, used for empty and error states.
class EmptyState extends StatelessWidget {
const EmptyState({
super.key,
required this.icon,
required this.title,
this.message,
this.action,
});
final IconData icon;
final String title;
final String? message;
final Widget? action;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 56, color: theme.colorScheme.primary),
const SizedBox(height: 16),
Text(title, style: theme.textTheme.titleMedium, textAlign: TextAlign.center),
if (message != null) ...[
const SizedBox(height: 8),
Text(
message!,
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
],
if (action != null) ...[const SizedBox(height: 20), action!],
],
),
),
);
}
}
+322
View File
@@ -0,0 +1,322 @@
import 'package:flutter/material.dart';
import '../models/article.dart';
import '../models/news_source.dart';
import '../state/app_state.dart';
import 'article_screen.dart';
import 'common.dart';
class FeedScreen extends StatefulWidget {
const FeedScreen({super.key, required this.state});
final AppState state;
@override
State<FeedScreen> createState() => _FeedScreenState();
}
class _FeedScreenState extends State<FeedScreen> {
SourceLanguage? _language;
String? _sourceId;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.state,
builder: (context, _) {
final state = widget.state;
final all = state.visibleArticles;
final visible = all
.where((a) => _language == null || a.source.language == _language)
.where((a) => _sourceId == null || a.source.id == _sourceId)
.toList();
final sourcesInFeed = {for (final a in all) a.source.id: a.source};
final hidden = state.hiddenCount;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(appName),
Text(
state.loading
? 'Loading feeds…'
: '${all.length} articles · last ${state.settings.maxAgeHours} h'
'${hidden > 0 ? ' · $hidden paid/ads hidden' : ''}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
actions: [
IconButton(
tooltip: 'Refresh',
onPressed: state.loading ? null : state.refresh,
icon: const Icon(Icons.refresh),
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(52),
child: _filterBar(sourcesInFeed.values.toList()),
),
),
body: Column(
children: [
if (state.loading) const LinearProgressIndicator(),
if (state.feedErrors.isNotEmpty && !state.loading)
_ErrorBanner(errors: state.feedErrors),
Expanded(
child: RefreshIndicator(
onRefresh: state.refresh,
child: _list(state, visible),
),
),
],
),
);
},
);
}
Widget _list(AppState state, List<Article> visible) {
if (visible.isEmpty) {
// ListView keeps pull-to-refresh working on the empty state.
return ListView(
children: [
const SizedBox(height: 80),
EmptyState(
icon: state.loading ? Icons.hourglass_top : Icons.inbox_outlined,
title: state.loading
? 'Fetching the latest news…'
: state.enabledSources.isEmpty
? 'No sources enabled'
: 'No articles found',
message: state.loading
? null
: state.enabledSources.isEmpty
? 'Turn on some feeds in the Sources tab.'
: 'Pull down to refresh or change the filter.',
),
],
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
itemCount: visible.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, i) => ArticleCard(
article: visible[i],
hasSummary: state.summaries[visible[i].id]?.text != null,
isPaid: state.paywall.isPaid(visible[i]),
onTap: () => openArticle(context, state, visible[i]),
),
);
}
Widget _filterBar(List<NewsSource> sources) {
sources.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return SizedBox(
height: 52,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
children: [
ChoiceChip(
label: const Text('All'),
selected: _language == null,
onSelected: (_) => setState(() => _language = null),
),
for (final lang in SourceLanguage.values) ...[
const SizedBox(width: 8),
ChoiceChip(
label: Text('${lang.flag} ${lang.label}'),
selected: _language == lang,
onSelected: (_) => setState(() => _language = lang),
),
],
const SizedBox(width: 8),
PopupMenuButton<String?>(
tooltip: 'Filter by source',
onSelected: (id) => setState(() => _sourceId = id),
itemBuilder: (_) => [
const PopupMenuItem(value: null, child: Text('All sources')),
for (final s in sources) PopupMenuItem(value: s.id, child: Text(s.name)),
],
child: Chip(
avatar: const Icon(Icons.filter_list, size: 18),
label: Text(_sourceId == null
? 'All sources'
: sources
.firstWhere((s) => s.id == _sourceId,
orElse: () => sources.isEmpty ? _placeholder : sources.first)
.name),
),
),
],
),
);
}
static const _placeholder = NewsSource(
id: '', name: 'All sources', feedUrl: '', homepage: '', language: SourceLanguage.en);
}
class ArticleCard extends StatelessWidget {
const ArticleCard({
super.key,
required this.article,
required this.onTap,
this.hasSummary = false,
this.isPaid = false,
});
final Article article;
final VoidCallback onTap;
final bool hasSummary;
/// Only shown when paid articles aren't hidden.
final bool isPaid;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(article.source.language.flag),
const SizedBox(width: 6),
Flexible(
child: Text(
article.source.name,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600),
),
),
if (article.published != null)
Text(' · ${timeAgo(article.published)}',
style: theme.textTheme.labelMedium?.copyWith(color: muted)),
if (isPaid) ...[
const SizedBox(width: 6),
Tooltip(
message: 'Paid article',
child: Icon(Icons.lock_outline, size: 14, color: muted),
),
],
if (hasSummary) ...[
const SizedBox(width: 6),
Icon(Icons.auto_awesome, size: 14, color: theme.colorScheme.tertiary),
],
],
),
const SizedBox(height: 6),
Text(
article.title,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(height: 1.3),
),
if (article.excerpt.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
article.excerpt,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(color: muted),
),
],
],
),
),
if (article.imageUrl != null) ...[
const SizedBox(width: 12),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
article.imageUrl!,
width: 84,
height: 84,
fit: BoxFit.cover,
webHtmlElementStrategy: WebHtmlElementStrategy.prefer,
errorBuilder: (_, __, ___) => const SizedBox(width: 84, height: 84),
),
),
],
],
),
),
),
);
}
}
class _ErrorBanner extends StatelessWidget {
const _ErrorBanner({required this.errors});
final Map<String, String> errors;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.errorContainer,
child: InkWell(
onTap: () => showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Some feeds failed'),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final e in errors.entries)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text.rich(TextSpan(children: [
TextSpan(
text: '${e.key}: ',
style: const TextStyle(fontWeight: FontWeight.bold)),
TextSpan(text: e.value),
])),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK')),
],
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(Icons.warning_amber, size: 18, color: theme.colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
'${errors.length} feed(s) could not be loaded. Tap for details.',
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
),
],
),
),
),
);
}
}
+75
View File
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import '../state/app_state.dart';
import 'briefing_screen.dart';
import 'feed_screen.dart';
import 'settings_screen.dart';
import 'sources_screen.dart';
class HomeShell extends StatefulWidget {
const HomeShell({super.key, required this.state});
final AppState state;
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
int _index = 0;
bool _sourcesChanged = false;
void _select(int index) {
// A text field left focused in Settings would otherwise get focus back
// (and open the keyboard) when returning from an article.
FocusManager.instance.primaryFocus?.unfocus();
// Source toggles only take effect on the next refresh; do it when the
// user leaves the Sources tab.
if (_index == 2 && index != 2 && _sourcesChanged) {
_sourcesChanged = false;
widget.state.refresh();
}
setState(() => _index = index);
}
@override
Widget build(BuildContext context) {
final state = widget.state;
return Scaffold(
body: IndexedStack(
index: _index,
children: [
FeedScreen(state: state),
BriefingScreen(state: state, openSettings: () => _select(3)),
SourcesScreen(state: state, onChanged: () => _sourcesChanged = true),
SettingsScreen(state: state),
].indexed
// Hidden tabs stay alive but must not hold or regain focus.
.map((e) => ExcludeFocus(excluding: e.$1 != _index, child: e.$2))
.toList(),
),
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: _select,
destinations: const [
NavigationDestination(
icon: Icon(Icons.newspaper_outlined),
selectedIcon: Icon(Icons.newspaper),
label: 'Today'),
NavigationDestination(
icon: Icon(Icons.auto_awesome_outlined),
selectedIcon: Icon(Icons.auto_awesome),
label: 'Briefing'),
NavigationDestination(
icon: Icon(Icons.rss_feed_outlined),
selectedIcon: Icon(Icons.rss_feed),
label: 'Sources'),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings'),
],
),
);
}
}
+271
View File
@@ -0,0 +1,271 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../models/app_settings.dart';
import '../models/news_source.dart';
import '../state/app_state.dart';
import 'common.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key, required this.state});
final AppState state;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
late AppSettings _draft;
final _apiKey = TextEditingController();
final _webhook = TextEditingController();
final _proxy = TextEditingController();
bool _showKey = false;
@override
void initState() {
super.initState();
_loadFrom(widget.state.settings);
// Settings load asynchronously at startup; pick them up once they arrive.
widget.state.addListener(_syncIfPristine);
}
void _loadFrom(AppSettings s) {
_draft = s;
_apiKey.text = s.claudeApiKey;
_webhook.text = s.n8nWebhookUrl;
_proxy.text = s.corsProxy;
}
bool _touched = false;
void _syncIfPristine() {
if (!_touched && mounted) setState(() => _loadFrom(widget.state.settings));
}
@override
void dispose() {
widget.state.removeListener(_syncIfPristine);
_apiKey.dispose();
_webhook.dispose();
_proxy.dispose();
super.dispose();
}
void _edit(AppSettings Function(AppSettings) change) {
setState(() {
_touched = true;
_draft = change(_draft);
});
}
Future<void> _save() async {
await widget.state.updateSettings(_draft.copyWith(
claudeApiKey: _apiKey.text.trim(),
n8nWebhookUrl: _webhook.text.trim(),
corsProxy: _proxy.text.trim(),
));
_touched = false;
if (mounted) {
FocusScope.of(context).unfocus();
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Settings saved')));
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Widget header(String text) => Padding(
padding: const EdgeInsets.fromLTRB(0, 24, 0, 8),
child: Text(text,
style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.primary)),
);
Widget hint(String text) => Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(text,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant)),
);
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
floatingActionButton: FloatingActionButton.extended(
onPressed: _save,
icon: const Icon(Icons.save_outlined),
label: const Text('Save'),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 96),
children: [
header('Appearance'),
SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.system,
icon: Icon(Icons.brightness_auto_outlined),
label: Text('System')),
ButtonSegment(
value: ThemeMode.light,
icon: Icon(Icons.light_mode_outlined),
label: Text('Light')),
ButtonSegment(
value: ThemeMode.dark,
icon: Icon(Icons.dark_mode_outlined),
label: Text('Dark')),
],
selected: {_draft.themeMode},
// Applies right away (no Save needed) so the change is visible.
onSelectionChanged: (s) {
setState(() => _draft = _draft.copyWith(themeMode: s.first));
widget.state
.updateSettings(widget.state.settings.copyWith(themeMode: s.first));
},
),
header('AI summaries'),
SegmentedButton<AiProvider>(
segments: [
for (final p in AiProvider.values) ButtonSegment(value: p, label: Text(p.label)),
],
selected: {_draft.aiProvider},
onSelectionChanged: (s) => _edit((d) => d.copyWith(aiProvider: s.first)),
),
if (_draft.aiProvider == AiProvider.claude) ...[
const SizedBox(height: 16),
TextField(
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
controller: _apiKey,
obscureText: !_showKey,
onChanged: (_) => _touched = true,
decoration: InputDecoration(
labelText: 'Claude API key',
hintText: 'sk-ant-…',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_showKey ? Icons.visibility_off : Icons.visibility),
onPressed: () => setState(() => _showKey = !_showKey),
),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: claudeModels.containsKey(_draft.claudeModel)
? _draft.claudeModel
: claudeModels.keys.first,
decoration:
const InputDecoration(labelText: 'Model', border: OutlineInputBorder()),
items: [
for (final e in claudeModels.entries)
DropdownMenuItem(value: e.key, child: Text(e.value)),
],
onChanged: (v) => _edit((d) => d.copyWith(claudeModel: v)),
),
Row(
children: [
Expanded(
child: hint('The key is stored in the device keystore and only sent '
'to api.anthropic.com.'),
),
TextButton(
onPressed: () =>
openUrl(context, 'https://console.anthropic.com/settings/keys'),
child: const Text('Get a key'),
),
],
),
],
if (_draft.aiProvider == AiProvider.n8n) ...[
const SizedBox(height: 16),
TextField(
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
controller: _webhook,
keyboardType: TextInputType.url,
onChanged: (_) => _touched = true,
decoration: const InputDecoration(
labelText: 'n8n webhook URL',
hintText: 'https://n8n.example.com/webhook/tech-news',
border: OutlineInputBorder(),
),
),
hint('The app POSTs articles to this webhook and expects JSON back. '
'See README.md for the request/response format.'),
],
header('Summary language'),
SegmentedButton<SourceLanguage>(
segments: [
for (final l in SourceLanguage.values)
ButtonSegment(value: l, label: Text('${l.flag} ${l.label}')),
],
selected: {_draft.summaryLanguage},
onSelectionChanged: (s) => _edit((d) => d.copyWith(summaryLanguage: s.first)),
),
hint('Summaries are written in this language, whatever language the source uses.'),
header('Feed'),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 12, label: Text('12 h')),
ButtonSegment(value: 24, label: Text('24 h')),
ButtonSegment(value: 48, label: Text('48 h')),
],
selected: {_draft.maxAgeHours},
onSelectionChanged: (s) => _edit((d) => d.copyWith(maxAgeHours: s.first)),
),
hint('Only show articles published within this time window.'),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Hide paid articles'),
subtitle: const Text('heise+, SPIEGEL+, Golem Plus, t3n Pro'),
value: _draft.hidePaid,
onChanged: (v) => _edit((d) => d.copyWith(hidePaid: v)),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Hide sponsored posts'),
subtitle: const Text('Posts marked as ads, e.g. "Anzeige:" on Golem'),
value: _draft.hideSponsored,
onChanged: (v) => _edit((d) => d.copyWith(hideSponsored: v)),
),
header('Original page view'),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Block ads and trackers'),
value: _draft.blockAds,
onChanged: (v) => _edit((d) => d.copyWith(blockAds: v)),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Hide cookie banners'),
subtitle: const Text('Nothing is accepted; sites treat it like a rejection'),
value: _draft.hideCookieBanners,
onChanged: (v) => _edit((d) => d.copyWith(hideCookieBanners: v)),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Accept cookie walls'),
subtitle: const Text('For sites that only show articles after you accept '
'(Golem). Ads and trackers stay blocked.'),
value: _draft.acceptConsentWalls,
onChanged: (v) => _edit((d) => d.copyWith(acceptConsentWalls: v)),
),
if (kIsWeb) ...[
header('Web (CORS proxy)'),
TextField(
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
controller: _proxy,
keyboardType: TextInputType.url,
onChanged: (_) => _touched = true,
decoration: const InputDecoration(
labelText: 'Proxy prefix',
hintText: 'https://corsproxy.example/?url=',
border: OutlineInputBorder(),
),
),
hint('Browsers block cross-origin feed requests. The feed URL is URL-encoded '
'and appended to this prefix. Not needed on Android, iOS or Windows.'),
],
],
),
);
}
}
+223
View File
@@ -0,0 +1,223 @@
import 'package:flutter/material.dart';
import '../models/news_source.dart';
import '../state/app_state.dart';
class SourcesScreen extends StatelessWidget {
const SourcesScreen({super.key, required this.state, required this.onChanged});
final AppState state;
/// Called when the selection changes so the feed can refresh later.
final VoidCallback onChanged;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: state,
builder: (context, _) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Sources'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(24),
child: Container(
alignment: AlignmentDirectional.centerStart,
padding: const EdgeInsetsDirectional.only(start: 16, bottom: 8),
child: Text(
'${state.enabledSourceIds.length} of ${state.allSources.length} enabled',
style: theme.textTheme.bodySmall,
),
),
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _addFeed(context),
icon: const Icon(Icons.add),
label: const Text('Add feed'),
),
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,
),
],
],
),
);
},
);
}
Future<void> _addFeed(BuildContext context) async {
final added = await showDialog<bool>(
context: context,
builder: (_) => _AddFeedDialog(state: state),
);
if (added == true) {
onChanged();
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Feed added')));
}
}
}
}
class _SourceTile extends StatelessWidget {
const _SourceTile({
required this.source,
required this.enabled,
required this.onToggle,
this.onDelete,
});
final NewsSource source;
final bool enabled;
final ValueChanged<bool> onToggle;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final host = Uri.tryParse(source.homepage)?.host ?? '';
final subtitle = [
if (source.description.isNotEmpty) source.description,
host.replaceFirst('www.', ''),
].join(' · ');
return SwitchListTile(
value: enabled,
onChanged: onToggle,
title: Text(source.name),
subtitle: Text(subtitle),
secondary: onDelete == null
? null
: IconButton(
tooltip: 'Remove',
icon: const Icon(Icons.delete_outline),
onPressed: onDelete,
),
);
}
}
class _AddFeedDialog extends StatefulWidget {
const _AddFeedDialog({required this.state});
final AppState state;
@override
State<_AddFeedDialog> createState() => _AddFeedDialogState();
}
class _AddFeedDialogState extends State<_AddFeedDialog> {
final _name = TextEditingController();
final _url = TextEditingController();
SourceLanguage _language = SourceLanguage.de;
bool _busy = false;
String? _error;
@override
void dispose() {
_name.dispose();
_url.dispose();
super.dispose();
}
Future<void> _submit() async {
final name = _name.text.trim();
final url = _url.text.trim();
if (name.isEmpty || !url.startsWith('http')) {
setState(() => _error = 'Enter a name and a feed URL starting with http.');
return;
}
setState(() {
_busy = true;
_error = null;
});
final error = await widget.state
.addCustomSource(name: name, feedUrl: url, language: _language);
if (!mounted) return;
if (error == null) {
Navigator.pop(context, true);
} else {
setState(() {
_busy = false;
_error = error;
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Add RSS / Atom feed'),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _name,
decoration: const InputDecoration(labelText: 'Name'),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextField(
controller: _url,
decoration: const InputDecoration(
labelText: 'Feed URL', hintText: 'https://example.com/feed.xml'),
keyboardType: TextInputType.url,
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 16),
SegmentedButton<SourceLanguage>(
segments: [
for (final l in SourceLanguage.values)
ButtonSegment(value: l, label: Text('${l.flag} ${l.label}')),
],
selected: {_language},
onSelectionChanged: (s) => setState(() => _language = s.first),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
],
),
),
actions: [
TextButton(
onPressed: _busy ? null : () => Navigator.pop(context, false),
child: const Text('Cancel')),
FilledButton(
onPressed: _busy ? null : _submit,
child: _busy
? const SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Add'),
),
],
);
}
}