Initial commit: Tagesbyte prototype

Flutter app that collects daily tech news from German and English RSS/Atom
feeds, with a reader view, AI article summaries and daily briefings (Claude
API or n8n webhook), paywall/sponsored filtering and ad/consent blocking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-16 14:53:37 +02:00
commit f12fb1dabb
159 files changed
+8738

No files matched your search

+546
View File
@@ -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)),
],
),
);
},
);
}
}
+191
View File
@@ -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),
),
);
}
}
+211
View File
@@ -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!,
],
),
);
}
}
+86
View File
@@ -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!],
],
),
),
);
}
}
+322
View File
@@ -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),
),
),
],
),
),
),
);
}
}
+75
View File
@@ -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'),
],
),
);
}
}
+271
View File
@@ -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.'),
],
],
),
);
}
}
+223
View File
@@ -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'),
),
],
);
}
}