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,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',
|
||||
),
|
||||
];
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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/',
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
})();
|
||||
''';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: ', '');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
})();
|
||||
''';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.'),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user