Add source kinds and the Learn tab with Learning sources (#2)

Every Source now has a kind: News, Learning or Fun. Curated Sources default
to News, and custom Sources persist their kind (ones saved before default
to News). Each kind has its own time window: News follows the 12/24/48 h
setting, Learning reaches back 14 days, Fun 3 days.

A new Learn tab between Briefing and Sources lists Learning articles with
the same paid/sponsored filters and reader view as Today. It is built from
a list of kinds so a Fun section can follow. Today and the Briefing only
use News. ByteByteGo is the first Learning source, on by default.

The home shell names its tabs instead of using hard-coded indexes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-16 15:44:52 +02:00
1 parent 8c8ffca5dc
commit 64c4a11105
12 files changed
+498 -32

No files matched your search

+12
View File
@@ -184,6 +184,18 @@ const defaultSources = <NewsSource>[
language: SourceLanguage.en,
description: 'Security and malware news',
),
// --- Learning -----------------------------------------------------------
NewsSource(
id: 'bytebytego',
name: 'ByteByteGo',
feedUrl: 'https://blog.bytebytego.com/feed',
homepage: 'https://blog.bytebytego.com',
language: SourceLanguage.en,
kind: SourceKind.learning,
description: 'System design explained with diagrams',
enabledByDefault: true,
),
];
/// The curated sources before the app remembered which ones it had seen.
+18
View File
@@ -8,6 +8,19 @@ enum SourceLanguage {
final String flag;
}
/// What a Source's articles are for; see GLOSSARY.md. Each kind has its own
/// section in the app and its own time window.
enum SourceKind {
news('News'),
learning('Learning'),
fun('Fun');
const SourceKind(this.label);
final String label;
}
/// A Source of any [kind]; the class name predates the kinds.
class NewsSource {
const NewsSource({
required this.id,
@@ -15,6 +28,7 @@ class NewsSource {
required this.feedUrl,
required this.homepage,
required this.language,
this.kind = SourceKind.news,
this.description = '',
this.enabledByDefault = false,
this.isCustom = false,
@@ -26,6 +40,7 @@ class NewsSource {
final String feedUrl;
final String homepage;
final SourceLanguage language;
final SourceKind kind;
final String description;
final bool enabledByDefault;
final bool isCustom;
@@ -40,6 +55,7 @@ class NewsSource {
'feedUrl': feedUrl,
'homepage': homepage,
'language': language.name,
'kind': kind.name,
'description': description,
};
@@ -49,6 +65,8 @@ class NewsSource {
feedUrl: json['feedUrl'] as String,
homepage: json['homepage'] as String? ?? '',
language: SourceLanguage.values.byName(json['language'] as String? ?? 'en'),
// Custom sources saved before kinds existed are News sources.
kind: SourceKind.values.asNameMap()[json['kind']] ?? SourceKind.news,
description: json['description'] as String? ?? '',
isCustom: true,
);
+9 -5
View File
@@ -23,14 +23,15 @@ class FeedService {
static const _timeout = Duration(seconds: 20);
/// Fetches all [sources] in parallel. Failing feeds are reported in
/// Fetches all [sources] in parallel and drops articles older than the age
/// [maxAgeOf] gives for their Source. Failing feeds are reported in
/// [FeedResult.errors] instead of failing the whole refresh.
///
/// [corsProxy] is only used on web, where browsers block cross-origin feed
/// requests. It is prepended to the feed URL, e.g. `https://proxy/?url=`.
Future<FeedResult> fetchAll(
List<NewsSource> sources, {
required Duration maxAge,
required Duration Function(NewsSource source) maxAgeOf,
String corsProxy = '',
}) async {
final errors = <String, String>{};
@@ -43,12 +44,15 @@ class FeedService {
}
}));
final cutoff = DateTime.now().toUtc().subtract(maxAge);
final now = DateTime.now().toUtc();
final seen = <String>{};
final articles = <Article>[
for (final list in results)
for (final (i, list) in results.indexed)
for (final a in list)
if ((a.published == null || a.published!.isAfter(cutoff)) && seen.add(a.id)) a,
if ((a.published == null ||
a.published!.isAfter(now.subtract(maxAgeOf(sources[i])))) &&
seen.add(a.id))
a,
];
articles.sort((a, b) {
final pa = a.published, pb = b.published;
+26 -7
View File
@@ -34,8 +34,10 @@ class AppState extends ChangeNotifier {
FeedService? feedService,
ArticleExtractor? extractor,
List<NewsSource> curatedSources = defaultSources,
Summarizer? summarizer,
}) : _feeds = feedService ?? FeedService(),
_curated = curatedSources,
_summarizerOverride = summarizer,
consent = ConsentService(),
paywall = PaywallService(_storage) {
_extractor = extractor ?? ArticleExtractor(consent: consent);
@@ -46,6 +48,9 @@ class AppState extends ChangeNotifier {
/// The curated catalog; [defaultSources] outside of tests.
final List<NewsSource> _curated;
/// Used instead of the AI set up in [settings]; for tests.
final Summarizer? _summarizerOverride;
late final ArticleExtractor _extractor;
final PaywallService paywall;
@@ -56,7 +61,7 @@ class AppState extends ChangeNotifier {
List<NewsSource> customSources = [];
Set<String> enabledSourceIds = {};
/// Everything the feeds returned; see [visibleArticles] for what is shown.
/// Everything the feeds returned; see [articlesOf] for what is shown.
List<Article> articles = [];
Map<String, String> feedErrors = {};
bool loading = false;
@@ -77,10 +82,22 @@ class AppState extends ChangeNotifier {
(settings.hidePaid && paywall.isPaid(a)) ||
(settings.hideSponsored && paywall.isSponsored(a));
/// Articles after the paid/sponsored filters. The feed and the briefing use these.
List<Article> get visibleArticles => articles.where((a) => !_isHidden(a)).toList();
/// Articles from Sources of [kind] after the paid/sponsored filters. Today
/// and the Briefing use News, the Learn tab the other kinds.
List<Article> articlesOf(SourceKind kind) =>
articles.where((a) => a.source.kind == kind && !_isHidden(a)).toList();
int get hiddenCount => articles.where(_isHidden).length;
/// Articles from Sources of [kind] that the paid/sponsored filters hide.
int hiddenCountOf(SourceKind kind) =>
articles.where((a) => a.source.kind == kind && _isHidden(a)).length;
/// How far back articles from Sources of [kind] reach. News follows the
/// user's setting; the other kinds age slower.
Duration windowOf(SourceKind kind) => switch (kind) {
SourceKind.news => Duration(hours: settings.maxAgeHours),
SourceKind.learning => const Duration(days: 14),
SourceKind.fun => const Duration(days: 3),
};
List<NewsSource> get allSources => [..._curated, ...customSources];
@@ -102,7 +119,7 @@ class AppState extends ChangeNotifier {
notifyListeners();
final result = await _feeds.fetchAll(
enabledSources,
maxAge: Duration(hours: settings.maxAgeHours),
maxAgeOf: (source) => windowOf(source.kind),
corsProxy: settings.corsProxy,
);
articles = result.articles;
@@ -148,6 +165,7 @@ class AppState extends ChangeNotifier {
required String name,
required String feedUrl,
required SourceLanguage language,
SourceKind kind = SourceKind.news,
}) async {
final uri = Uri.tryParse(feedUrl);
final source = NewsSource(
@@ -156,6 +174,7 @@ class AppState extends ChangeNotifier {
feedUrl: feedUrl,
homepage: uri == null ? '' : '${uri.scheme}://${uri.host}',
language: language,
kind: kind,
isCustom: true,
);
try {
@@ -226,7 +245,7 @@ class AppState extends ChangeNotifier {
// --- AI ------------------------------------------------------------------
Summarizer? get _summarizer => switch (settings.aiProvider) {
Summarizer? get _summarizer => _summarizerOverride ?? switch (settings.aiProvider) {
_ when !settings.aiConfigured => null,
AiProvider.claude => ClaudeSummarizer(
apiKey: settings.claudeApiKey, model: settings.claudeModel),
@@ -270,7 +289,7 @@ class AppState extends ChangeNotifier {
Future<void> generateBriefing() async {
final summarizer = _summarizer;
if (summarizer == null || briefingLoading) return;
final articles = visibleArticles;
final articles = articlesOf(SourceKind.news);
if (articles.isEmpty) {
briefingError = 'No articles loaded yet. Pull to refresh the feed first.';
notifyListeners();
+3 -2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../models/briefing.dart';
import '../models/news_source.dart';
import '../state/app_state.dart';
import 'article_screen.dart';
import 'briefing_history_screen.dart';
@@ -59,7 +60,7 @@ class BriefingScreen extends StatelessWidget {
if (state.briefingLoading) {
return EmptyState(
icon: Icons.hourglass_top,
title: 'Reading ${state.visibleArticles.length} headlines…',
title: 'Reading ${state.articlesOf(SourceKind.news).length} headlines…',
message: 'This usually takes 10-60 seconds.',
action: const SizedBox(width: 160, child: LinearProgressIndicator()),
);
@@ -69,7 +70,7 @@ class BriefingScreen extends StatelessWidget {
icon: const Icon(Icons.auto_awesome),
label: Text(state.loading
? 'Waiting for feeds…'
: 'Generate from ${state.visibleArticles.length} articles'),
: 'Generate from ${state.articlesOf(SourceKind.news).length} articles'),
);
if (briefing == null) {
return EmptyState(
+2 -2
View File
@@ -25,13 +25,13 @@ class _FeedScreenState extends State<FeedScreen> {
listenable: widget.state,
builder: (context, _) {
final state = widget.state;
final all = state.visibleArticles;
final all = state.articlesOf(SourceKind.news);
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;
final hidden = state.hiddenCountOf(SourceKind.news);
return Scaffold(
appBar: AppBar(
+29 -14
View File
@@ -3,9 +3,13 @@ import 'package:flutter/material.dart';
import '../state/app_state.dart';
import 'briefing_screen.dart';
import 'feed_screen.dart';
import 'learn_screen.dart';
import 'settings_screen.dart';
import 'sources_screen.dart';
/// The tabs in the order they appear in the navigation bar.
enum _Tab { today, briefing, learn, sources, settings }
class HomeShell extends StatefulWidget {
const HomeShell({super.key, required this.state});
@@ -16,20 +20,20 @@ class HomeShell extends StatefulWidget {
}
class _HomeShellState extends State<HomeShell> {
int _index = 0;
_Tab _tab = _Tab.today;
bool _sourcesChanged = false;
void _select(int index) {
void _select(_Tab tab) {
// 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) {
if (_tab == _Tab.sources && tab != _Tab.sources && _sourcesChanged) {
_sourcesChanged = false;
widget.state.refresh();
}
setState(() => _index = index);
setState(() => _tab = tab);
}
@override
@@ -37,20 +41,27 @@ class _HomeShellState extends State<HomeShell> {
final state = widget.state;
return Scaffold(
body: IndexedStack(
index: _index,
index: _tab.index,
children: [
FeedScreen(state: state),
BriefingScreen(state: state, openSettings: () => _select(3)),
SourcesScreen(state: state, onChanged: () => _sourcesChanged = true),
SettingsScreen(state: state),
].indexed
for (final tab in _Tab.values)
// Hidden tabs stay alive but must not hold or regain focus.
.map((e) => ExcludeFocus(excluding: e.$1 != _index, child: e.$2))
.toList(),
ExcludeFocus(
excluding: tab != _tab,
child: switch (tab) {
_Tab.today => FeedScreen(state: state),
_Tab.briefing => BriefingScreen(
state: state, openSettings: () => _select(_Tab.settings)),
_Tab.learn => LearnScreen(state: state),
_Tab.sources =>
SourcesScreen(state: state, onChanged: () => _sourcesChanged = true),
_Tab.settings => SettingsScreen(state: state),
},
),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: _select,
selectedIndex: _tab.index,
onDestinationSelected: (i) => _select(_Tab.values[i]),
destinations: const [
NavigationDestination(
icon: Icon(Icons.newspaper_outlined),
@@ -60,6 +71,10 @@ class _HomeShellState extends State<HomeShell> {
icon: Icon(Icons.auto_awesome_outlined),
selectedIcon: Icon(Icons.auto_awesome),
label: 'Briefing'),
NavigationDestination(
icon: Icon(Icons.school_outlined),
selectedIcon: Icon(Icons.school),
label: 'Learn'),
NavigationDestination(
icon: Icon(Icons.rss_feed_outlined),
selectedIcon: Icon(Icons.rss_feed),
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import '../models/news_source.dart';
import '../state/app_state.dart';
import 'article_screen.dart';
import 'feed_screen.dart';
/// Reading that keeps for days, from the Sources that aren't News: one
/// section per kind, each with its own time window.
class LearnScreen extends StatelessWidget {
const LearnScreen({super.key, required this.state});
final AppState state;
/// The sections, top to bottom.
static const kinds = [SourceKind.learning];
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: state,
builder: (context, _) => Scaffold(
appBar: AppBar(
title: const Text('Learn'),
actions: [
IconButton(
tooltip: 'Refresh',
onPressed: state.loading ? null : state.refresh,
icon: const Icon(Icons.refresh),
),
],
),
body: Column(
children: [
if (state.loading) const LinearProgressIndicator(),
Expanded(
child: RefreshIndicator(
onRefresh: state.refresh,
child: ListView(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 24),
children: [for (final kind in kinds) ..._section(context, kind)],
),
),
),
],
),
),
);
}
List<Widget> _section(BuildContext context, SourceKind kind) {
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
final articles = state.articlesOf(kind);
final hidden = state.hiddenCountOf(kind);
final hasSources = state.enabledSources.any((s) => s.kind == kind);
return [
Padding(
padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(kind.label,
style: theme.textTheme.titleMedium?.copyWith(color: theme.colorScheme.primary)),
Text(
'${articles.length} articles · last ${_describe(state.windowOf(kind))}'
'${hidden > 0 ? ' · $hidden paid/ads hidden' : ''}',
style: theme.textTheme.bodySmall?.copyWith(color: muted),
),
],
),
),
if (articles.isEmpty && !state.loading)
Padding(
padding: const EdgeInsets.fromLTRB(4, 8, 4, 16),
child: Text(
hasSources
? 'Nothing new in this window. Pull down to refresh.'
: 'No ${kind.label} sources enabled. Turn some on in the Sources tab.',
style: theme.textTheme.bodyMedium?.copyWith(color: muted),
),
),
for (final article in articles)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ArticleCard(
article: article,
hasSummary: state.summaries[article.id]?.text != null,
isPaid: state.paywall.isPaid(article),
onTap: () => openArticle(context, state, article),
),
),
];
}
static String _describe(Duration window) =>
window.inHours < 72 ? '${window.inHours} h' : '${window.inDays} days';
}