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,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