Switch newly added default Sources on once (#1)
The app now remembers which curated Sources it has seen. A Source that is on by default is enabled the first time it appears, also on installs with saved toggles; Sources seen before keep the user's choice. Installs from before this change count the current catalog as seen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f35ac9b806
commit
ea37a23c08
4 files changed
+135
-5
No files matched your search
@@ -185,3 +185,12 @@ const defaultSources = <NewsSource>[
|
||||
description: 'Security and malware news',
|
||||
),
|
||||
];
|
||||
|
||||
/// The curated sources before the app remembered which ones it had seen.
|
||||
/// Installs from then count these as seen, so switched-off ones stay off.
|
||||
const legacySeenSourceIds = {
|
||||
'heise', 'golem', 't3n', 'computerbase', 'spiegel-netzwelt', 'zeit-digital',
|
||||
'netzpolitik', 'heise-security', 'heise-developer', 'caschy', 'derstandard-web',
|
||||
'borncity', 'verge', 'arstechnica', 'techcrunch', 'hackernews', 'mit-tr', 'wired',
|
||||
'engadget', 'theregister', 'bleepingcomputer',
|
||||
};
|
||||
@@ -74,6 +74,13 @@ class Storage {
|
||||
Future<void> saveEnabledSourceIds(Set<String> ids) =>
|
||||
_prefs.setStringList('enabled_sources', ids.toList());
|
||||
|
||||
/// Curated sources the app has already offered, so each one that is on by
|
||||
/// default switches on only once. Null means "never saved".
|
||||
Set<String>? loadSeenSourceIds() => _prefs.getStringList('seen_sources')?.toSet();
|
||||
|
||||
Future<void> saveSeenSourceIds(Set<String> ids) =>
|
||||
_prefs.setStringList('seen_sources', ids.toList());
|
||||
|
||||
List<NewsSource> loadCustomSources() {
|
||||
final raw = _prefs.getString('custom_sources');
|
||||
if (raw == null) return [];
|
||||
|
||||
@@ -29,8 +29,13 @@ class SummaryState {
|
||||
}
|
||||
|
||||
class AppState extends ChangeNotifier {
|
||||
AppState(this._storage, {FeedService? feedService, ArticleExtractor? extractor})
|
||||
: _feeds = feedService ?? FeedService(),
|
||||
AppState(
|
||||
this._storage, {
|
||||
FeedService? feedService,
|
||||
ArticleExtractor? extractor,
|
||||
List<NewsSource> curatedSources = defaultSources,
|
||||
}) : _feeds = feedService ?? FeedService(),
|
||||
_curated = curatedSources,
|
||||
consent = ConsentService(),
|
||||
paywall = PaywallService(_storage) {
|
||||
_extractor = extractor ?? ArticleExtractor(consent: consent);
|
||||
@@ -38,6 +43,9 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
final Storage _storage;
|
||||
final FeedService _feeds;
|
||||
|
||||
/// The curated catalog; [defaultSources] outside of tests.
|
||||
final List<NewsSource> _curated;
|
||||
late final ArticleExtractor _extractor;
|
||||
final PaywallService paywall;
|
||||
|
||||
@@ -74,7 +82,7 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
int get hiddenCount => articles.where(_isHidden).length;
|
||||
|
||||
List<NewsSource> get allSources => [...defaultSources, ...customSources];
|
||||
List<NewsSource> get allSources => [..._curated, ...customSources];
|
||||
|
||||
List<NewsSource> get enabledSources =>
|
||||
allSources.where((s) => enabledSourceIds.contains(s.id)).toList();
|
||||
@@ -82,8 +90,7 @@ class AppState extends ChangeNotifier {
|
||||
Future<void> init() async {
|
||||
settings = await _storage.loadSettings();
|
||||
customSources = _storage.loadCustomSources();
|
||||
enabledSourceIds = _storage.loadEnabledSourceIds() ??
|
||||
defaultSources.where((s) => s.enabledByDefault).map((s) => s.id).toSet();
|
||||
await _enableNewDefaultSources();
|
||||
briefings = await _storage.loadBriefingHistory();
|
||||
notifyListeners();
|
||||
await refresh();
|
||||
@@ -111,6 +118,24 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
// --- Sources -------------------------------------------------------------
|
||||
|
||||
/// Switches on curated sources that are on by default the first time the
|
||||
/// app sees them, so they also reach installs with saved toggles. Sources
|
||||
/// seen before keep whatever the user chose.
|
||||
Future<void> _enableNewDefaultSources() async {
|
||||
final saved = _storage.loadEnabledSourceIds();
|
||||
final seen = _storage.loadSeenSourceIds() ??
|
||||
(saved == null ? const <String>{} : legacySeenSourceIds);
|
||||
final unseen = _curated.where((s) => !seen.contains(s.id)).toList();
|
||||
enabledSourceIds = {
|
||||
...?saved,
|
||||
for (final s in unseen)
|
||||
if (s.enabledByDefault) s.id,
|
||||
};
|
||||
if (unseen.isEmpty) return;
|
||||
await _storage.saveSeenSourceIds({...seen, ...unseen.map((s) => s.id)});
|
||||
await _storage.saveEnabledSourceIds(enabledSourceIds);
|
||||
}
|
||||
|
||||
Future<void> setSourceEnabled(NewsSource source, bool enabled) async {
|
||||
enabled ? enabledSourceIds.add(source.id) : enabledSourceIds.remove(source.id);
|
||||
notifyListeners();
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:daily_tech_news/data/default_sources.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/services/feed_service.dart';
|
||||
import 'package:daily_tech_news/services/storage.dart';
|
||||
import 'package:daily_tech_news/state/app_state.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Returns no articles, so starting the app doesn't hit the network.
|
||||
class _NoFeeds extends FeedService {
|
||||
@override
|
||||
Future<FeedResult> fetchAll(List<NewsSource> sources,
|
||||
{required Duration maxAge, String corsProxy = ''}) async =>
|
||||
const FeedResult([], {});
|
||||
}
|
||||
|
||||
NewsSource _source(String id, {bool onByDefault = false}) => NewsSource(
|
||||
id: id,
|
||||
name: id,
|
||||
feedUrl: 'https://example.com/$id.xml',
|
||||
homepage: 'https://example.com',
|
||||
language: SourceLanguage.en,
|
||||
enabledByDefault: onByDefault,
|
||||
);
|
||||
|
||||
/// Starts the app the way a launch does, against whatever is in preferences.
|
||||
Future<AppState> _launch(List<NewsSource> curatedSources) async {
|
||||
final state =
|
||||
AppState(await Storage.open(), feedService: _NoFeeds(), curatedSources: curatedSources);
|
||||
await state.init();
|
||||
return state;
|
||||
}
|
||||
|
||||
void main() {
|
||||
final heise = _source('heise', onByDefault: true);
|
||||
final golem = _source('golem', onByDefault: true);
|
||||
final wired = _source('wired');
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test('a fresh install starts with exactly the on-by-default Sources', () async {
|
||||
final state = await _launch([heise, golem, wired]);
|
||||
|
||||
expect(state.enabledSourceIds, {'heise', 'golem'});
|
||||
});
|
||||
|
||||
test('a newly added on-by-default Source shows up enabled after the update', () async {
|
||||
final before = await _launch([heise, golem, wired]);
|
||||
await before.setSourceEnabled(wired, true);
|
||||
|
||||
final verge = _source('verge', onByDefault: true);
|
||||
final after = await _launch([heise, golem, wired, verge]);
|
||||
|
||||
expect(after.enabledSourceIds, {'heise', 'golem', 'wired', 'verge'});
|
||||
});
|
||||
|
||||
test('a Source the user switched off stays off across updates', () async {
|
||||
final before = await _launch([heise, golem, wired]);
|
||||
await before.setSourceEnabled(golem, false);
|
||||
|
||||
final verge = _source('verge', onByDefault: true);
|
||||
await _launch([heise, golem, wired, verge]);
|
||||
final later = await _launch([heise, golem, wired, verge]);
|
||||
|
||||
expect(later.enabledSourceIds, {'heise', 'verge'});
|
||||
});
|
||||
|
||||
test('a newly added Source that is off by default stays off', () async {
|
||||
final before = await _launch([heise, golem, wired]);
|
||||
await before.setSourceEnabled(wired, true);
|
||||
|
||||
final engadget = _source('engadget');
|
||||
final after = await _launch([heise, golem, wired, engadget]);
|
||||
|
||||
expect(after.enabledSourceIds, {'heise', 'golem', 'wired'});
|
||||
});
|
||||
|
||||
test('an install from before the app remembered seen Sources keeps its toggles', () async {
|
||||
// Toggles saved by an earlier version: golem, t3n and others switched off.
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'enabled_sources': ['heise', 'verge', 'wired'],
|
||||
});
|
||||
|
||||
final newSource = _source('new-source', onByDefault: true);
|
||||
final state = await _launch([...defaultSources, newSource]);
|
||||
|
||||
expect(state.enabledSourceIds, {'heise', 'verge', 'wired', 'new-source'});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user