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

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