diff --git a/lib/locator.dart b/lib/locator.dart index 8c6806bc..4fa2f7b4 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -1,6 +1,7 @@ import 'package:get_it/get_it.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/services/api/api.service.dart'; +import 'package:twonly/src/services/news.service.dart'; import 'package:twonly/src/services/user.service.dart'; final GetIt locator = GetIt.instance; @@ -9,9 +10,11 @@ void setupLocator() { locator ..registerLazySingleton(UserService.new) ..registerLazySingleton(ApiService.new) - ..registerLazySingleton(TwonlyDB.new); + ..registerLazySingleton(TwonlyDB.new) + ..registerLazySingleton(NewsService.new); } UserService get userService => locator(); ApiService get apiService => locator(); TwonlyDB get twonlyDB => locator(); +NewsService get newsService => locator(); diff --git a/lib/main.dart b/lib/main.dart index b1114ec1..c375f023 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -175,6 +175,13 @@ Future postStartupTasks() async { // 2. Service initializations unawaited(finishStartedPreprocessing()); unawaited(createPushAvatars()); + unawaited(newsService.init().then((_) { + final lastDownload = newsService.lastDownloadedAt; + if (lastDownload == null || + DateTime.now().difference(lastDownload) >= const Duration(days: 7)) { + newsService.fetchFeed(); + } + })); unawaited(UserDiscoveryService.verifyInitializationOnStartup()); diff --git a/lib/src/constants/routes.keys.dart b/lib/src/constants/routes.keys.dart index 43fc9cb0..9df0ea84 100644 --- a/lib/src/constants/routes.keys.dart +++ b/lib/src/constants/routes.keys.dart @@ -48,6 +48,7 @@ class Routes { static const String settingsHelpFaqVerifyBadge = '/settings/help/faq/verifybadge'; static const String settingsHelpContactUs = '/settings/help/contact_us'; + static const String settingsHelpNews = '/settings/help/news'; static const String settingsHelpDiagnostics = '/settings/help/diagnostics'; static const String settingsHelpUserStudy = '/settings/help/user_study'; static const String settingsHelpUserStudyQuestionnaire = diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index e7ec988e..73b4bfce 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -4183,6 +4183,12 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Memories Backup'** String get memoriesBackupTitle; + + /// No description provided for @settingsHelpNews. + /// + /// In en, this message translates to: + /// **'News'** + String get settingsHelpNews; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 68185e49..37df22b6 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -2421,4 +2421,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get memoriesBackupTitle => 'Memories Backup'; + + @override + String get settingsHelpNews => 'Neuigkeiten'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 9e93e569..c87ff548 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -2400,4 +2400,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get memoriesBackupTitle => 'Memories Backup'; + + @override + String get settingsHelpNews => 'News'; } diff --git a/lib/src/localization/translations b/lib/src/localization/translations index f72c6178..bb353aa5 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit f72c61787096a44e2252058c08e4c14a0a8e5cff +Subproject commit bb353aa572623d20578f470d2c683ead79669ed8 diff --git a/lib/src/providers/routing.provider.dart b/lib/src/providers/routing.provider.dart index 44228345..2b239d00 100644 --- a/lib/src/providers/routing.provider.dart +++ b/lib/src/providers/routing.provider.dart @@ -37,6 +37,7 @@ import 'package:twonly/src/visual/views/settings/help/diagnostics.view.dart'; import 'package:twonly/src/visual/views/settings/help/faq.view.dart'; import 'package:twonly/src/visual/views/settings/help/faq/verification_badge_faq.view.dart'; import 'package:twonly/src/visual/views/settings/help/help.view.dart'; +import 'package:twonly/src/visual/views/settings/help/news.view.dart'; import 'package:twonly/src/visual/views/settings/notification.view.dart'; import 'package:twonly/src/visual/views/settings/privacy.view.dart'; import 'package:twonly/src/visual/views/settings/privacy/block_users.view.dart'; @@ -259,6 +260,10 @@ final routerProvider = GoRouter( path: 'contact_us', builder: (context, state) => const ContactUsView(), ), + GoRoute( + path: 'news', + builder: (context, state) => const NewsView(), + ), GoRoute( path: 'diagnostics', builder: (context, state) => const DiagnosticsView(), diff --git a/lib/src/services/news.service.dart b/lib/src/services/news.service.dart new file mode 100644 index 00000000..116c9638 --- /dev/null +++ b/lib/src/services/news.service.dart @@ -0,0 +1,257 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart'; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/utils/log.dart'; + +class BlogEntry { + BlogEntry({ + required this.title, + required this.link, + required this.guid, + required this.description, + required this.imageUrl, + this.pubDate, + }); + + factory BlogEntry.fromJson(Map json) => BlogEntry( + title: (json['title'] as String?) ?? '', + link: (json['link'] as String?) ?? '', + guid: (json['guid'] as String?) ?? '', + description: (json['description'] as String?) ?? '', + imageUrl: (json['imageUrl'] as String?) ?? '', + pubDate: json['pubDate'] != null ? DateTime.tryParse(json['pubDate'] as String) : null, + ); + + final String title; + final String link; + final String guid; + final String description; + final String imageUrl; + final DateTime? pubDate; + + Map toJson() => { + 'title': title, + 'link': link, + 'guid': guid, + 'description': description, + 'imageUrl': imageUrl, + 'pubDate': pubDate?.toIso8601String(), + }; + + @override + String toString() { + return 'BlogEntry(title: $title, link: $link, guid: $guid, description: $description, imageUrl: $imageUrl, pubDate: $pubDate)'; + } +} + +class NewsService { + List entries = []; + Set openedGuids = {}; + DateTime? lastDownloadedAt; + bool hasLoadedBefore = false; + + final ValueNotifier unreadCountNotifier = ValueNotifier(0); + + File get _cacheFile => File(join(AppEnvironment.supportDir, 'news_feed.json')); + + Future init() async { + try { + await loadCache(); + } catch (e) { + Log.error('Failed to load news cache: $e'); + } + } + + Future loadCache() async { + final file = _cacheFile; + if (!file.existsSync()) { + return; + } + final content = await file.readAsString(); + final json = jsonDecode(content) as Map; + + hasLoadedBefore = json['hasLoadedBefore'] as bool? ?? false; + + final lastDownloadedStr = json['lastDownloadedAt'] as String?; + if (lastDownloadedStr != null) { + lastDownloadedAt = DateTime.tryParse(lastDownloadedStr); + } + + final openedList = json['openedGuids'] as List? ?? []; + openedGuids = openedList.map((e) => e.toString()).toSet(); + + final entriesList = json['entries'] as List? ?? []; + entries = entriesList + .map((e) => BlogEntry.fromJson(e as Map)) + .toList(); + + _updateUnreadCount(); + } + + Future saveCache() async { + final file = _cacheFile; + final json = { + 'hasLoadedBefore': hasLoadedBefore, + 'lastDownloadedAt': lastDownloadedAt?.toIso8601String(), + 'openedGuids': openedGuids.toList(), + 'entries': entries.map((e) => e.toJson()).toList(), + }; + await file.writeAsString(jsonEncode(json)); + } + + void _updateUnreadCount() { + var count = 0; + for (final entry in entries) { + if (!openedGuids.contains(entry.guid)) { + count++; + } + } + unreadCountNotifier.value = count; + } + + Future markAllAsRead() async { + for (final entry in entries) { + openedGuids.add(entry.guid); + } + _updateUnreadCount(); + await saveCache(); + } + + Future fetchFeed({http.Client? client, bool force = false}) async { + try { + final lang = ui.PlatformDispatcher.instance.locale.languageCode; + final url = lang == 'de' + ? 'https://twonly.eu/de/blog/rss.xml' + : 'https://twonly.eu/en/blog/rss.xml'; + + final response = client != null + ? await client.get(Uri.parse(url)) + : await http.get(Uri.parse(url)); + if (response.statusCode != 200) { + Log.error('Failed to download RSS feed: code ${response.statusCode}'); + return; + } + + final xml = utf8.decode(response.bodyBytes); + final newEntries = _parseRss(xml); + + if (!hasLoadedBefore) { + // First run: mark all fetched as already opened + for (final entry in newEntries) { + openedGuids.add(entry.guid); + } + hasLoadedBefore = true; + } + + entries = newEntries; + lastDownloadedAt = DateTime.now(); + + _updateUnreadCount(); + await saveCache(); + Log.info('Successfully fetched ${entries.length} news entries. Unread count: ${unreadCountNotifier.value}'); + } catch (e) { + Log.error('Failed to fetch news feed: $e'); + } + } + + List _parseRss(String xml) { + final entries = []; + final itemRegex = RegExp(r'([\s\S]*?)<\/item>'); + final matches = itemRegex.allMatches(xml); + + for (final match in matches) { + final itemContent = match.group(1) ?? ''; + + final titleMatch = RegExp(r'([\s\S]*?)<\/title>').firstMatch(itemContent); + final title = _stripCdata(titleMatch?.group(1) ?? ''); + + final linkMatch = RegExp(r'<link>([\s\S]*?)<\/link>').firstMatch(itemContent); + final link = _stripCdata(linkMatch?.group(1) ?? ''); + + final guidMatch = RegExp(r'<guid[^>]*?>([\s\S]*?)<\/guid>').firstMatch(itemContent); + final guid = _stripCdata(guidMatch?.group(1) ?? link); + + final descMatch = RegExp(r'<description>([\s\S]*?)<\/description>').firstMatch(itemContent); + final descriptionRaw = _stripCdata(descMatch?.group(1) ?? ''); + final description = descriptionRaw.replaceAll(RegExp('<[^>]*>'), ''); + + var imageUrl = ''; + final enclosureMatch = RegExp(r'<enclosure\s+[^>]*?url="([^"]+)"').firstMatch(itemContent); + if (enclosureMatch != null) { + imageUrl = enclosureMatch.group(1) ?? ''; + } + if (imageUrl.isEmpty) { + final mediaMatch = RegExp(r'<media:content\s+[^>]*?url="([^"]+)"').firstMatch(itemContent); + if (mediaMatch != null) { + imageUrl = mediaMatch.group(1) ?? ''; + } + } + + final pubDateMatch = RegExp(r'<pubDate>([\s\S]*?)<\/pubDate>').firstMatch(itemContent); + DateTime? pubDate; + if (pubDateMatch != null) { + final pubDateStr = pubDateMatch.group(1) ?? ''; + pubDate = DateTime.tryParse(pubDateStr); + pubDate ??= _parseRfc2822(pubDateStr); + } + + entries.add(BlogEntry( + title: title, + link: link, + guid: guid, + description: description, + imageUrl: imageUrl, + pubDate: pubDate, + )); + } + + return entries; + } + + String _stripCdata(String input) { + var s = input.trim(); + if (s.startsWith('<![CDATA[')) { + s = s.substring(9); + } + if (s.endsWith(']]>')) { + s = s.substring(0, s.length - 3); + } + return s.trim(); + } + + DateTime? _parseRfc2822(String dateString) { + try { + var cleaned = dateString.trim(); + if (cleaned.contains(',')) { + cleaned = cleaned.split(',')[1].trim(); + } + final parts = cleaned.split(RegExp(r'\s+')); + if (parts.length < 4) return null; + + final day = int.tryParse(parts[0]) ?? 1; + final monthStr = parts[1].toLowerCase(); + final year = int.tryParse(parts[2]) ?? DateTime.now().year; + + final timeParts = parts[3].split(':'); + final hour = timeParts.isNotEmpty ? (int.tryParse(timeParts[0]) ?? 0) : 0; + final minute = timeParts.length > 1 ? (int.tryParse(timeParts[1]) ?? 0) : 0; + final second = timeParts.length > 2 ? (int.tryParse(timeParts[2]) ?? 0) : 0; + + const months = { + 'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6, + 'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12 + }; + final monthStringPart = monthStr.length > 3 ? monthStr.substring(0, 3) : monthStr; + final month = months[monthStringPart] ?? 1; + + return DateTime.utc(year, month, day, hour, minute, second); + } catch (e) { + Log.warn('Failed to parse date: $e'); + return null; + } + } +} diff --git a/lib/src/visual/views/chats/chat_list.view.dart b/lib/src/visual/views/chats/chat_list.view.dart index 58de7f31..cc8078fa 100644 --- a/lib/src/visual/views/chats/chat_list.view.dart +++ b/lib/src/visual/views/chats/chat_list.view.dart @@ -17,8 +17,8 @@ import 'package:twonly/src/visual/components/connection_status.comp.dart'; import 'package:twonly/src/visual/components/notification_badge.comp.dart'; import 'package:twonly/src/visual/themes/light.dart'; import 'package:twonly/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart'; -import 'package:twonly/src/visual/views/chats/chat_list_components/feedback_btn.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_list_components/group_list_item.comp.dart'; +import 'package:twonly/src/visual/views/chats/chat_list_components/news_btn.comp.dart'; import 'package:twonly/src/visual/views/onboarding/setup/components/finish_setup.comp.dart'; import 'package:twonly/src/visual/views/settings/backup/components/missing_backup_setup.comp.dart'; import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/missing_recovery_contacts.comp.dart'; @@ -184,7 +184,7 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie ], ), actions: [ - const FeedbackIconButtonComp(), + const NewsIconButtonComp(), ValueListenableBuilder<int>( valueListenable: _badgeCount, builder: (context, badgeCount, child) { diff --git a/lib/src/visual/views/chats/chat_list_components/news_btn.comp.dart b/lib/src/visual/views/chats/chat_list_components/news_btn.comp.dart new file mode 100644 index 00000000..a914bac3 --- /dev/null +++ b/lib/src/visual/views/chats/chat_list_components/news_btn.comp.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:go_router/go_router.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/constants/routes.keys.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/notification_badge.comp.dart'; + +class NewsIconButtonComp extends StatelessWidget { + const NewsIconButtonComp({super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder<int>( + valueListenable: newsService.unreadCountNotifier, + builder: (context, count, child) { + return NotificationBadgeComp( + count: count.toString(), + child: IconButton( + onPressed: () => context.push(Routes.settingsHelpNews), + color: Colors.grey, + tooltip: context.lang.settingsHelpNews, + icon: const FaIcon(FontAwesomeIcons.newspaper, size: 19), + ), + ); + }, + ); + } +} diff --git a/lib/src/visual/views/settings/help/news.view.dart b/lib/src/visual/views/settings/help/news.view.dart new file mode 100644 index 00000000..f65c6f7a --- /dev/null +++ b/lib/src/visual/views/settings/help/news.view.dart @@ -0,0 +1,120 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class NewsView extends StatefulWidget { + const NewsView({super.key}); + + @override + State<NewsView> createState() => _NewsViewState(); +} + +class _NewsViewState extends State<NewsView> { + @override + void initState() { + super.initState(); + // Mark all as read when entering the page + newsService.markAllAsRead(); + } + + @override + Widget build(BuildContext context) { + final entries = newsService.entries; + + return Scaffold( + appBar: AppBar( + title: Text(context.lang.settingsHelpNews), + ), + body: entries.isEmpty + ? Center( + child: Text( + 'No news articles found.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Colors.grey, + ), + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: entries.length, + itemBuilder: (context, index) { + final entry = entries[index]; + return Card( + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 2, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: InkWell( + onTap: () => launchUrl( + Uri.parse(entry.link), + mode: LaunchMode.externalApplication, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (entry.imageUrl.isNotEmpty) + CachedNetworkImage( + imageUrl: entry.imageUrl, + height: 180, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + height: 180, + color: Colors.grey.withValues(alpha: 0.1), + child: const Center( + child: CircularProgressIndicator(), + ), + ), + errorWidget: (context, url, error) => Container( + height: 180, + color: Colors.grey.withValues(alpha: 0.1), + child: const Icon(Icons.broken_image, size: 50, color: Colors.grey), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (entry.pubDate != null) ...[ + Text( + DateFormat.yMMMMd( + Localizations.localeOf(context).toString(), + ).format(entry.pubDate!), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + const SizedBox(height: 8), + ], + Text( + entry.title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + entry.description, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.color.onSurface.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} diff --git a/test/services/news_service_test.dart b/test/services/news_service_test.dart new file mode 100644 index 00000000..041e0e6b --- /dev/null +++ b/test/services/news_service_test.dart @@ -0,0 +1,178 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:path/path.dart'; +import 'package:twonly/globals.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/services/news.service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late NewsService newsServiceInstance; + + setUp(() async { + tempDir = Directory.systemTemp.createTempSync('news_service_test'); + AppEnvironment.initTesting( + customCacheDir: join(tempDir.path, 'cache'), + customSupportDir: join(tempDir.path, 'support'), + ); + Directory(AppEnvironment.supportDir).createSync(recursive: true); + Directory(AppEnvironment.cacheDir).createSync(recursive: true); + + await locator.reset(); + locator.registerSingleton<NewsService>(NewsService()); + newsServiceInstance = newsService; + }); + + tearDown(() async { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + const rssXml = ''' +<?xml version="1.0" encoding="UTF-8" ?> +<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/"> +<channel> + <title>twonly Blog + https://twonly.eu/en/blog/ + Latest news, privacy insights, and development updates from twonly. + en + Mon, 20 Jul 2026 12:23:32 GMT + + + <![CDATA[Finding Friends Without Phone Numbers]]> + https://twonly.eu/en/blog/2026-mutual-friends.html + https://twonly.eu/en/blog/2026-mutual-friends.html + Sun, 03 May 2026 00:00:00 GMT + + + + + + +'''; + + const rssXmlWithTwoItems = ''' + + + + twonly Blog + https://twonly.eu/en/blog/ + Latest news, privacy insights, and development updates from twonly. + en + Mon, 20 Jul 2026 12:23:32 GMT + + + <![CDATA[Finding Friends Without Phone Numbers]]> + https://twonly.eu/en/blog/2026-mutual-friends.html + https://twonly.eu/en/blog/2026-mutual-friends.html + Sun, 03 May 2026 00:00:00 GMT + + + + + + + <![CDATA[New Update Available]]> + https://twonly.eu/en/blog/new-update.html + https://twonly.eu/en/blog/new-update.html + Mon, 20 Jul 2026 12:00:00 GMT + + new version of twonly has been released.]]> + + + +'''; + + test('Initial fetch feed marks all articles as read', () async { + final mockClient = MockClient((request) async { + return http.Response.bytes(utf8.encode(rssXml), 200); + }); + + await newsServiceInstance.init(); + expect(newsServiceInstance.hasLoadedBefore, isFalse); + expect(newsServiceInstance.unreadCountNotifier.value, 0); + + await newsServiceInstance.fetchFeed(client: mockClient); + + expect(newsServiceInstance.hasLoadedBefore, isTrue); + expect(newsServiceInstance.entries.length, 1); + expect(newsServiceInstance.unreadCountNotifier.value, 0); + expect( + newsServiceInstance.openedGuids.contains( + 'https://twonly.eu/en/blog/2026-mutual-friends.html', + ), + isTrue, + ); + }); + + test('Subsequent fetch feed detects new articles as unread', () async { + final mockClient1 = MockClient((request) async { + return http.Response.bytes(utf8.encode(rssXml), 200); + }); + + await newsServiceInstance.init(); + await newsServiceInstance.fetchFeed(client: mockClient1); + + expect(newsServiceInstance.unreadCountNotifier.value, 0); + + final mockClient2 = MockClient((request) async { + return http.Response.bytes(utf8.encode(rssXmlWithTwoItems), 200); + }); + + await newsServiceInstance.fetchFeed(client: mockClient2); + + expect(newsServiceInstance.entries.length, 2); + expect(newsServiceInstance.unreadCountNotifier.value, 1); + expect( + newsServiceInstance.entries[1].description, + 'A brand new version of twonly has been released.', + ); + }); + + test('markAllAsRead clears unread count', () async { + final mockClient1 = MockClient((request) async { + return http.Response.bytes(utf8.encode(rssXml), 200); + }); + + await newsServiceInstance.init(); + await newsServiceInstance.fetchFeed(client: mockClient1); + + final mockClient2 = MockClient((request) async { + return http.Response.bytes(utf8.encode(rssXmlWithTwoItems), 200); + }); + + await newsServiceInstance.fetchFeed(client: mockClient2); + expect(newsServiceInstance.unreadCountNotifier.value, 1); + + await newsServiceInstance.markAllAsRead(); + expect(newsServiceInstance.unreadCountNotifier.value, 0); + }); + + test('Cache loads correctly and restores state', () async { + final mockClient = MockClient((request) async { + return http.Response.bytes(utf8.encode(rssXml), 200); + }); + + await newsServiceInstance.init(); + await newsServiceInstance.fetchFeed(client: mockClient); + + final newServiceInstance = NewsService(); + await newServiceInstance.init(); + + expect(newServiceInstance.hasLoadedBefore, isTrue); + expect(newServiceInstance.entries.length, 1); + expect(newServiceInstance.unreadCountNotifier.value, 0); + expect( + newServiceInstance.openedGuids.contains( + 'https://twonly.eu/en/blog/2026-mutual-friends.html', + ), + isTrue, + ); + }); +}