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,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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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? ?? '',
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user