diff --git a/lib/data/default_sources.dart b/lib/data/default_sources.dart index fe1e6f5..ce84ec1 100644 --- a/lib/data/default_sources.dart +++ b/lib/data/default_sources.dart @@ -185,3 +185,12 @@ const defaultSources = [ 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', +}; diff --git a/lib/services/storage.dart b/lib/services/storage.dart index dab305a..a4f0155 100644 --- a/lib/services/storage.dart +++ b/lib/services/storage.dart @@ -74,6 +74,13 @@ class Storage { Future saveEnabledSourceIds(Set 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? loadSeenSourceIds() => _prefs.getStringList('seen_sources')?.toSet(); + + Future saveSeenSourceIds(Set ids) => + _prefs.setStringList('seen_sources', ids.toList()); + List loadCustomSources() { final raw = _prefs.getString('custom_sources'); if (raw == null) return []; diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index ae25723..b1d022d 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -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 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 _curated; late final ArticleExtractor _extractor; final PaywallService paywall; @@ -74,7 +82,7 @@ class AppState extends ChangeNotifier { int get hiddenCount => articles.where(_isHidden).length; - List get allSources => [...defaultSources, ...customSources]; + List get allSources => [..._curated, ...customSources]; List get enabledSources => allSources.where((s) => enabledSourceIds.contains(s.id)).toList(); @@ -82,8 +90,7 @@ class AppState extends ChangeNotifier { Future 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 _enableNewDefaultSources() async { + final saved = _storage.loadEnabledSourceIds(); + final seen = _storage.loadSeenSourceIds() ?? + (saved == null ? const {} : 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 setSourceEnabled(NewsSource source, bool enabled) async { enabled ? enabledSourceIds.add(source.id) : enabledSourceIds.remove(source.id); notifyListeners(); diff --git a/test/source_selection_test.dart b/test/source_selection_test.dart new file mode 100644 index 0000000..8ce401a --- /dev/null +++ b/test/source_selection_test.dart @@ -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 fetchAll(List 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 _launch(List 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'}); + }); +}