diff --git a/README.md b/README.md index a2998bc..6820bc8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/ui/sources_screen.dart b/lib/ui/sources_screen.dart index a7927c3..3c3fea8 100644 --- a/lib/ui/sources_screen.dart +++ b/lib/ui/sources_screen.dart @@ -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 _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 _addFeed(BuildContext context) async { final added = await showDialog( 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( + 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)), diff --git a/test/sources_tab_test.dart b/test/sources_tab_test.dart new file mode 100644 index 0000000..005398d --- /dev/null +++ b/test/sources_tab_test.dart @@ -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 launch(WidgetTester tester, List sources) async => + (await tester.runAsync(() => launchApp(sources)))!; + + Future 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 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 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); + }); +}