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

+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();
}
}