Author SHA1 Message Date
CedricandClaude Opus 5.5 f205452b25 Group the Sources tab by kind and pick a kind when adding a feed (#4)
The Sources tab shows News, Learning and Fun sections, each split by
language; kinds or languages without Sources get no header. The add-feed
dialog has a kind picker that defaults to News.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:50:43 +02:00
3 changed files with 205 additions and 25 deletions

No files matched your search

+2 -1
View File
@@ -34,7 +34,8 @@ or write a daily briefing. It's meant to replace the n8n → Discord workflow.
valid for a day) and reuses the cookie for reader view and the in-app page.
With it off, the app shows the teaser and an "Open website anyway" option.
- **Sources**: 23 curated feeds (13 German, 10 English), toggle each one, or add any
RSS/Atom feed (it's validated before being added).
RSS/Atom feed as a News, Learning or Fun source (it's validated before being
added). The tab groups Sources by kind, then by language.
- **Dark mode**: System / Light / Dark in Settings (applies instantly). The
original-page view follows it too: when the phone is dark, sites use their own
dark design or Android darkens them; when only the app is dark, the app inverts
+50 -24
View File
@@ -40,29 +40,7 @@ class SourcesScreen extends StatelessWidget {
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,
),
],
for (final kind in SourceKind.values) ..._kindSection(context, kind),
],
),
);
@@ -70,6 +48,44 @@ class SourcesScreen extends StatelessWidget {
);
}
/// One section per kind, split by language. Kinds and languages without
/// Sources are left out instead of showing an empty header.
List<Widget> _kindSection(BuildContext context, SourceKind kind) {
final theme = Theme.of(context);
final sources = state.allSources.where((s) => s.kind == kind).toList();
if (sources.isEmpty) return const [];
return [
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 0),
child: Text(kind.label, style: theme.textTheme.titleMedium),
),
for (final lang in SourceLanguage.values)
if (sources.any((s) => s.language == lang)) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text('${lang.flag} ${lang.label}',
style: theme.textTheme.titleSmall
?.copyWith(color: theme.colorScheme.primary)),
),
for (final source in sources.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,
@@ -134,6 +150,7 @@ class _AddFeedDialogState extends State<_AddFeedDialog> {
final _name = TextEditingController();
final _url = TextEditingController();
SourceLanguage _language = SourceLanguage.de;
SourceKind _kind = SourceKind.news;
bool _busy = false;
String? _error;
@@ -156,7 +173,7 @@ class _AddFeedDialogState extends State<_AddFeedDialog> {
_error = null;
});
final error = await widget.state
.addCustomSource(name: name, feedUrl: url, language: _language);
.addCustomSource(name: name, feedUrl: url, language: _language, kind: _kind);
if (!mounted) return;
if (error == null) {
Navigator.pop(context, true);
@@ -199,6 +216,15 @@ class _AddFeedDialogState extends State<_AddFeedDialog> {
selected: {_language},
onSelectionChanged: (s) => setState(() => _language = s.first),
),
const SizedBox(height: 12),
SegmentedButton<SourceKind>(
segments: [
for (final k in SourceKind.values)
ButtonSegment(value: k, label: Text(k.label)),
],
selected: {_kind},
onSelectionChanged: (s) => setState(() => _kind = s.first),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
+153
View File
@@ -0,0 +1,153 @@
import 'package:daily_tech_news/models/news_source.dart';
import 'package:daily_tech_news/state/app_state.dart';
import 'package:daily_tech_news/ui/home_shell.dart';
import 'package:daily_tech_news/ui/sources_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'support/fake_app.dart';
NewsSource _source(String id, SourceKind kind, SourceLanguage language) => NewsSource(
id: id,
name: id,
feedUrl: 'https://example.com/$id.xml',
homepage: 'https://example.com',
language: language,
kind: kind,
enabledByDefault: true,
);
void main() {
final heise = _source('heise', SourceKind.news, SourceLanguage.de);
final verge = _source('verge', SourceKind.news, SourceLanguage.en);
final bytebytego = _source('bytebytego', SourceKind.learning, SourceLanguage.en);
final xkcd = _source('xkcd', SourceKind.fun, SourceLanguage.en);
const german = '🇩🇪 Deutsch';
const english = '🇬🇧 English';
setUp(() => SharedPreferences.setMockInitialValues({}));
Future<AppState> launch(WidgetTester tester, List<NewsSource> sources) async =>
(await tester.runAsync(() => launchApp(sources)))!;
Future<void> showSources(WidgetTester tester, AppState state) async {
tester.view.physicalSize = const Size(800, 2400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(home: SourcesScreen(state: state, onChanged: () {})));
}
/// Asserts the [finders] each match once on screen, from top to bottom.
void expectInOrder(WidgetTester tester, List<Finder> finders) {
final ys = [for (final f in finders) tester.getTopLeft(f).dy];
expect(ys, [...ys]..sort());
expect(ys.toSet().length, ys.length);
}
testWidgets('the Sources tab groups News, Learning and Fun, each split by language',
(tester) async {
final state = await launch(tester, [xkcd, bytebytego, verge, heise]);
await showSources(tester, state);
expectInOrder(tester, [
find.text('News'),
find.text(german).at(0),
find.text('heise'),
find.text(english).at(0),
find.text('verge'),
find.text('Learning'),
find.text(english).at(1),
find.text('bytebytego'),
find.text('Fun'),
find.text(english).at(2),
find.text('xkcd'),
]);
});
testWidgets('a kind without Sources gets no section', (tester) async {
final state = await launch(tester, [verge]);
await showSources(tester, state);
expect(find.text('News'), findsOneWidget);
expect(find.text('Learning'), findsNothing);
expect(find.text('Fun'), findsNothing);
expect(find.text(german), findsNothing);
});
Future<void> addFeed(WidgetTester tester, String name, {String? kind}) async {
await tester.tap(find.text('Add feed'));
await tester.pumpAndSettle();
final dialog = find.byType(AlertDialog);
await tester.enterText(
find.descendant(of: dialog, matching: find.widgetWithText(TextField, 'Name')), name);
await tester.enterText(
find.descendant(of: dialog, matching: find.widgetWithText(TextField, 'Feed URL')),
'https://example.com/$name.xml');
if (kind != null) {
await tester.tap(find.descendant(of: dialog, matching: find.text(kind)));
await tester.pump();
}
await tester.tap(find.descendant(of: dialog, matching: find.text('Add')));
await tester.pumpAndSettle();
}
testWidgets('a feed added without picking a kind is a News source', (tester) async {
final state = await launch(tester, [verge, bytebytego]);
await showSources(tester, state);
await addFeed(tester, 'myfeed');
expectInOrder(tester, [find.text('News'), find.text('myfeed'), find.text('Learning')]);
});
testWidgets('a feed added as Learning shows up in the Learning section', (tester) async {
final state = await launch(tester, [verge, bytebytego]);
await showSources(tester, state);
await addFeed(tester, 'myblog', kind: 'Learning');
expectInOrder(tester, [find.text('Learning'), find.text('myblog')]);
});
testWidgets('a feed added as Fun gets a Fun section', (tester) async {
final state = await launch(tester, [verge]);
await showSources(tester, state);
await addFeed(tester, 'mycomic', kind: 'Fun');
expectInOrder(tester, [find.text('News'), find.text('Fun'), find.text('mycomic')]);
});
testWidgets('a custom Learning source shows up in the Learn tab, not in Today',
(tester) async {
final feeds = FakeFeeds({
verge.feedUrl: [('News from this morning', const Duration(hours: 2))],
'https://example.com/myblog.xml': [('Explainer from my blog', const Duration(days: 2))],
});
final state = (await tester.runAsync(() => launchApp([verge], feedService: feeds)))!;
tester.view.physicalSize = const Size(800, 2400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(home: HomeShell(state: state)));
Finder tab(String label) =>
find.descendant(of: find.byType(NavigationBar), matching: find.text(label));
await tester.tap(tab('Sources'));
await tester.pump();
await addFeed(tester, 'myblog', kind: 'Learning');
await tester.tap(tab('Learn'));
await tester.pumpAndSettle();
expect(find.text('Explainer from my blog'), findsOneWidget);
await tester.tap(tab('Today'));
await tester.pumpAndSettle();
expect(find.text('News from this morning'), findsOneWidget);
expect(find.text('Explainer from my blog'), findsNothing);
});
}