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:
commit
f12fb1dabb
159 files changed
+8738
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user