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:
1 parent
8c8ffca5dc
commit
64c4a11105
12 files changed
+498
-32
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in new issue
Block a user