Keep per-kind facts on SourceKind and the Briefing's input in one place (review items 4, 5, 6, 7)
- SourceKind carries whether it feeds the Briefing, its fixed time window (Learning, Fun) and whether reading is picture-led (Fun); windowOf and the reader read from it. - AppState.briefingArticles is the one place that says only News sources feed the Briefing; generateBriefing and the Briefing tab use it. - articlesOf, hiddenCountOf and briefingArticles share one filter. - FeedService.fetchAll takes windowOf: like AppState.windowOf. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
49982c5467
commit
246564f1d9
8 files changed
+80
-30
No files matched your search
+19
-4
@@ -11,13 +11,28 @@ enum SourceLanguage {
|
||||
/// 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');
|
||||
news('News', feedsBriefing: true),
|
||||
learning('Learning', fixedWindow: Duration(days: 14)),
|
||||
fun('Fun', fixedWindow: Duration(days: 3), pictureLed: true);
|
||||
|
||||
const SourceKind(this.label);
|
||||
const SourceKind(
|
||||
this.label, {
|
||||
this.feedsBriefing = false,
|
||||
this.fixedWindow,
|
||||
this.pictureLed = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
|
||||
/// Only News sources feed the Briefing.
|
||||
final bool feedsBriefing;
|
||||
|
||||
/// How far back articles reach; null where the user sets it (News).
|
||||
final Duration? fixedWindow;
|
||||
|
||||
/// Comics and memes are the picture: the reader shows it whole in the body
|
||||
/// rather than as a cropped banner.
|
||||
final bool pictureLed;
|
||||
}
|
||||
|
||||
/// A Source of any [kind]; see GLOSSARY.md.
|
||||
|
||||
@@ -23,15 +23,15 @@ class FeedService {
|
||||
|
||||
static const _timeout = Duration(seconds: 20);
|
||||
|
||||
/// Fetches all [sources] in parallel and drops articles older than the age
|
||||
/// [maxAgeOf] gives for their Source. Failing feeds are reported in
|
||||
/// Fetches all [sources] in parallel and drops articles older than the time
|
||||
/// window [windowOf] 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<Source> sources, {
|
||||
required Duration Function(Source source) maxAgeOf,
|
||||
required Duration Function(Source source) windowOf,
|
||||
String corsProxy = '',
|
||||
}) async {
|
||||
final errors = <String, String>{};
|
||||
@@ -50,7 +50,7 @@ class FeedService {
|
||||
for (final (i, list) in results.indexed)
|
||||
for (final a in list)
|
||||
if ((a.published == null ||
|
||||
a.published!.isAfter(now.subtract(maxAgeOf(sources[i])))) &&
|
||||
a.published!.isAfter(now.subtract(windowOf(sources[i])))) &&
|
||||
seen.add(a.id))
|
||||
a,
|
||||
];
|
||||
|
||||
+17
-12
@@ -82,22 +82,27 @@ class AppState extends ChangeNotifier {
|
||||
(settings.hidePaid && paywall.isPaid(a)) ||
|
||||
(settings.hideSponsored && paywall.isSponsored(a));
|
||||
|
||||
/// Articles from Sources whose kind passes [test] that the paid/sponsored
|
||||
/// filters show, or with [hidden], those they hide.
|
||||
Iterable<Article> _articlesWhere(bool Function(SourceKind kind) test,
|
||||
{bool hidden = false}) =>
|
||||
articles.where((a) => test(a.source.kind) && _isHidden(a) == hidden);
|
||||
|
||||
/// 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();
|
||||
/// uses News, the Learn tab the other kinds.
|
||||
List<Article> articlesOf(SourceKind kind) => _articlesWhere((k) => k == kind).toList();
|
||||
|
||||
/// 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;
|
||||
int hiddenCountOf(SourceKind kind) => _articlesWhere((k) => k == kind, hidden: true).length;
|
||||
|
||||
/// What the Briefing is made from: the shown articles of the kinds that
|
||||
/// feed it (News).
|
||||
List<Article> get briefingArticles => _articlesWhere((k) => k.feedsBriefing).toList();
|
||||
|
||||
/// 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),
|
||||
};
|
||||
Duration windowOf(SourceKind kind) =>
|
||||
kind.fixedWindow ?? Duration(hours: settings.maxAgeHours);
|
||||
|
||||
List<Source> get allSources => [..._curated, ...customSources];
|
||||
|
||||
@@ -119,7 +124,7 @@ class AppState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
final result = await _feeds.fetchAll(
|
||||
enabledSources,
|
||||
maxAgeOf: (source) => windowOf(source.kind),
|
||||
windowOf: (source) => windowOf(source.kind),
|
||||
corsProxy: settings.corsProxy,
|
||||
);
|
||||
articles = result.articles;
|
||||
@@ -292,7 +297,7 @@ class AppState extends ChangeNotifier {
|
||||
Future<void> generateBriefing() async {
|
||||
final summarizer = _summarizer;
|
||||
if (summarizer == null || briefingLoading) return;
|
||||
final articles = articlesOf(SourceKind.news);
|
||||
final articles = briefingArticles;
|
||||
if (articles.isEmpty) {
|
||||
briefingError = 'No articles loaded yet. Pull to refresh the feed first.';
|
||||
notifyListeners();
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
import '../models/article.dart';
|
||||
import '../models/source.dart';
|
||||
import '../services/article_extractor.dart';
|
||||
import '../services/consent_service.dart';
|
||||
import '../services/content_blocking.dart';
|
||||
@@ -276,9 +275,7 @@ class _ReaderView extends StatelessWidget {
|
||||
builder: (context, snapshot) {
|
||||
final loaded = snapshot.data;
|
||||
var blocks = loaded?.blocks ?? const <ReaderBlock>[];
|
||||
// Comics and memes are the picture: show it whole in the body
|
||||
// rather than as a cropped banner.
|
||||
final pictureLed = article.source.kind == SourceKind.fun;
|
||||
final pictureLed = article.source.kind.pictureLed;
|
||||
final banner = pictureLed ? null : article.imageUrl;
|
||||
// The page's own hero image duplicates the feed image shown on top.
|
||||
if (banner != null) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/briefing.dart';
|
||||
import '../models/source.dart';
|
||||
import '../state/app_state.dart';
|
||||
import 'article_screen.dart';
|
||||
import 'briefing_history_screen.dart';
|
||||
@@ -60,7 +59,7 @@ class BriefingScreen extends StatelessWidget {
|
||||
if (state.briefingLoading) {
|
||||
return EmptyState(
|
||||
icon: Icons.hourglass_top,
|
||||
title: 'Reading ${state.articlesOf(SourceKind.news).length} headlines…',
|
||||
title: 'Reading ${state.briefingArticles.length} headlines…',
|
||||
message: 'This usually takes 10-60 seconds.',
|
||||
action: const SizedBox(width: 160, child: LinearProgressIndicator()),
|
||||
);
|
||||
@@ -70,7 +69,7 @@ class BriefingScreen extends StatelessWidget {
|
||||
icon: const Icon(Icons.auto_awesome),
|
||||
label: Text(state.loading
|
||||
? 'Waiting for feeds…'
|
||||
: 'Generate from ${state.articlesOf(SourceKind.news).length} articles'),
|
||||
: 'Generate from ${state.briefingArticles.length} articles'),
|
||||
);
|
||||
if (briefing == null) {
|
||||
return EmptyState(
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:daily_tech_news/data/default_sources.dart';
|
||||
import 'package:daily_tech_news/models/app_settings.dart';
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/briefing.dart';
|
||||
import 'package:daily_tech_news/models/source.dart';
|
||||
import 'package:daily_tech_news/services/summarizer.dart';
|
||||
import 'package:daily_tech_news/ui/briefing_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -62,6 +65,37 @@ void main() {
|
||||
expect(_titles(summarizer.briefingInput!), ['News from this morning']);
|
||||
});
|
||||
|
||||
test('the Briefing reads the shown News articles, not Learning, Fun or hidden ones', () async {
|
||||
final xkcd = testSource('xkcd', SourceKind.fun);
|
||||
final state = await launchApp([verge, bytebytego, xkcd], feeds: {
|
||||
verge.feedUrl: [('News from this morning', const Duration(hours: 2)),
|
||||
('Sponsored: Buy our phone', const Duration(hours: 3))],
|
||||
bytebytego.feedUrl: [('Explainer from today', const Duration(hours: 1))],
|
||||
xkcd.feedUrl: [('Comic from today', const Duration(hours: 1))],
|
||||
});
|
||||
|
||||
expect(_titles(state.briefingArticles), ['News from this morning']);
|
||||
});
|
||||
|
||||
testWidgets('the Briefing tab counts only the articles the Briefing is made from',
|
||||
(tester) async {
|
||||
final state = (await tester.runAsync(() async {
|
||||
final state = await launchApp([verge, bytebytego], feeds: {
|
||||
verge.feedUrl: [('News from this morning', const Duration(hours: 2))],
|
||||
bytebytego.feedUrl: [('Explainer from today', const Duration(hours: 1)),
|
||||
('Explainer from yesterday', const Duration(days: 1))],
|
||||
});
|
||||
await state.updateSettings(
|
||||
state.settings.copyWith(aiProvider: AiProvider.claude, claudeApiKey: 'key'));
|
||||
return state;
|
||||
}))!;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: BriefingScreen(state: state, openSettings: () {})));
|
||||
|
||||
expect(find.text('Generate from 1 articles'), findsOneWidget);
|
||||
});
|
||||
|
||||
test('News follows the window setting', () async {
|
||||
SharedPreferences.setMockInitialValues({'max_age_hours': 48});
|
||||
final state = await launchApp([verge], feeds: {
|
||||
|
||||
@@ -10,7 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
class _NoFeeds extends FeedService {
|
||||
@override
|
||||
Future<FeedResult> fetchAll(List<Source> sources,
|
||||
{required Duration Function(Source source) maxAgeOf, String corsProxy = ''}) async =>
|
||||
{required Duration Function(Source source) windowOf, String corsProxy = ''}) async =>
|
||||
const FeedResult([], {});
|
||||
}
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@ class FakeFeeds extends FeedService {
|
||||
|
||||
@override
|
||||
Future<FeedResult> fetchAll(List<Source> sources,
|
||||
{required Duration Function(Source source) maxAgeOf, String corsProxy = ''}) {
|
||||
{required Duration Function(Source source) windowOf, String corsProxy = ''}) {
|
||||
refreshes++;
|
||||
return super.fetchAll(sources, maxAgeOf: maxAgeOf, corsProxy: corsProxy);
|
||||
return super.fetchAll(sources, windowOf: windowOf, corsProxy: corsProxy);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
Reference in new issue
Block a user