mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-01 06:24:07 +00:00
news for new blog post
This commit is contained in:
parent
fb9acc690a
commit
9f8cb6f735
13 changed files with 616 additions and 4 deletions
|
|
@ -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>(UserService.new)
|
||||
..registerLazySingleton<ApiService>(ApiService.new)
|
||||
..registerLazySingleton<TwonlyDB>(TwonlyDB.new);
|
||||
..registerLazySingleton<TwonlyDB>(TwonlyDB.new)
|
||||
..registerLazySingleton<NewsService>(NewsService.new);
|
||||
}
|
||||
|
||||
UserService get userService => locator<UserService>();
|
||||
ApiService get apiService => locator<ApiService>();
|
||||
TwonlyDB get twonlyDB => locator<TwonlyDB>();
|
||||
NewsService get newsService => locator<NewsService>();
|
||||
|
|
|
|||
|
|
@ -175,6 +175,13 @@ Future<void> 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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2421,4 +2421,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get memoriesBackupTitle => 'Memories Backup';
|
||||
|
||||
@override
|
||||
String get settingsHelpNews => 'Neuigkeiten';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2400,4 +2400,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get memoriesBackupTitle => 'Memories Backup';
|
||||
|
||||
@override
|
||||
String get settingsHelpNews => 'News';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit f72c61787096a44e2252058c08e4c14a0a8e5cff
|
||||
Subproject commit bb353aa572623d20578f470d2c683ead79669ed8
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
257
lib/src/services/news.service.dart
Normal file
257
lib/src/services/news.service.dart
Normal file
|
|
@ -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<String, dynamic> 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<String, dynamic> 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<BlogEntry> entries = [];
|
||||
Set<String> openedGuids = {};
|
||||
DateTime? lastDownloadedAt;
|
||||
bool hasLoadedBefore = false;
|
||||
|
||||
final ValueNotifier<int> unreadCountNotifier = ValueNotifier<int>(0);
|
||||
|
||||
File get _cacheFile => File(join(AppEnvironment.supportDir, 'news_feed.json'));
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
await loadCache();
|
||||
} catch (e) {
|
||||
Log.error('Failed to load news cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadCache() async {
|
||||
final file = _cacheFile;
|
||||
if (!file.existsSync()) {
|
||||
return;
|
||||
}
|
||||
final content = await file.readAsString();
|
||||
final json = jsonDecode(content) as Map<String, dynamic>;
|
||||
|
||||
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<dynamic>? ?? [];
|
||||
openedGuids = openedList.map((e) => e.toString()).toSet();
|
||||
|
||||
final entriesList = json['entries'] as List<dynamic>? ?? [];
|
||||
entries = entriesList
|
||||
.map((e) => BlogEntry.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
_updateUnreadCount();
|
||||
}
|
||||
|
||||
Future<void> 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<void> markAllAsRead() async {
|
||||
for (final entry in entries) {
|
||||
openedGuids.add(entry.guid);
|
||||
}
|
||||
_updateUnreadCount();
|
||||
await saveCache();
|
||||
}
|
||||
|
||||
Future<void> 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<BlogEntry> _parseRss(String xml) {
|
||||
final entries = <BlogEntry>[];
|
||||
final itemRegex = RegExp(r'<item>([\s\S]*?)<\/item>');
|
||||
final matches = itemRegex.allMatches(xml);
|
||||
|
||||
for (final match in matches) {
|
||||
final itemContent = match.group(1) ?? '';
|
||||
|
||||
final titleMatch = RegExp(r'<title>([\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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
120
lib/src/visual/views/settings/help/news.view.dart
Normal file
120
lib/src/visual/views/settings/help/news.view.dart
Normal file
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
178
test/services/news_service_test.dart
Normal file
178
test/services/news_service_test.dart
Normal file
|
|
@ -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</title>
|
||||
<link>https://twonly.eu/en/blog/</link>
|
||||
<description>Latest news, privacy insights, and development updates from twonly.</description>
|
||||
<language>en</language>
|
||||
<lastBuildDate>Mon, 20 Jul 2026 12:23:32 GMT</lastBuildDate>
|
||||
<atom:link href="https://twonly.eu/en/blog/rss.xml" rel="self" type="application/rss+xml" />
|
||||
<item>
|
||||
<title><![CDATA[Finding Friends Without Phone Numbers]]></title>
|
||||
<link>https://twonly.eu/en/blog/2026-mutual-friends.html</link>
|
||||
<guid isPermaLink="true">https://twonly.eu/en/blog/2026-mutual-friends.html</guid>
|
||||
<pubDate>Sun, 03 May 2026 00:00:00 GMT</pubDate>
|
||||
<author><![CDATA[Tobias Müller]]></author>
|
||||
<description><![CDATA[Finding your friends on a messenger app usually means giving up your phone number. We’ve built a way to find and verify your contacts through the people you already know, all while keeping your personal identifiers private. No phone numbers, no tracking.]]></description>
|
||||
<enclosure url="https://twonly.eu/assets/blog/2026-mutual-friends/2026-mutual-friends.webp" length="258020" type="image/webp" />
|
||||
<media:content url="https://twonly.eu/assets/blog/2026-mutual-friends/2026-mutual-friends.webp" medium="image" type="image/webp" />
|
||||
</item>
|
||||
</channel>
|
||||
</rss>''';
|
||||
|
||||
const rssXmlWithTwoItems = '''
|
||||
<?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</title>
|
||||
<link>https://twonly.eu/en/blog/</link>
|
||||
<description>Latest news, privacy insights, and development updates from twonly.</description>
|
||||
<language>en</language>
|
||||
<lastBuildDate>Mon, 20 Jul 2026 12:23:32 GMT</lastBuildDate>
|
||||
<atom:link href="https://twonly.eu/en/blog/rss.xml" rel="self" type="application/rss+xml" />
|
||||
<item>
|
||||
<title><![CDATA[Finding Friends Without Phone Numbers]]></title>
|
||||
<link>https://twonly.eu/en/blog/2026-mutual-friends.html</link>
|
||||
<guid isPermaLink="true">https://twonly.eu/en/blog/2026-mutual-friends.html</guid>
|
||||
<pubDate>Sun, 03 May 2026 00:00:00 GMT</pubDate>
|
||||
<author><![CDATA[Tobias Müller]]></author>
|
||||
<description><![CDATA[Finding your friends on a messenger app usually means giving up your phone number. We’ve built a way to find and verify your contacts through the people you already know, all while keeping your personal identifiers private. No phone numbers, no tracking.]]></description>
|
||||
<enclosure url="https://twonly.eu/assets/blog/2026-mutual-friends/2026-mutual-friends.webp" length="258020" type="image/webp" />
|
||||
<media:content url="https://twonly.eu/assets/blog/2026-mutual-friends/2026-mutual-friends.webp" medium="image" type="image/webp" />
|
||||
</item>
|
||||
<item>
|
||||
<title><![CDATA[New Update Available]]></title>
|
||||
<link>https://twonly.eu/en/blog/new-update.html</link>
|
||||
<guid isPermaLink="true">https://twonly.eu/en/blog/new-update.html</guid>
|
||||
<pubDate>Mon, 20 Jul 2026 12:00:00 GMT</pubDate>
|
||||
<author><![CDATA[Tobias Müller]]></author>
|
||||
<description><![CDATA[A brand <a href="https://twonly.eu">new version</a> of twonly has been released.]]></description>
|
||||
<enclosure url="https://twonly.eu/assets/blog/new-update/new-update.webp" length="12345" type="image/webp" />
|
||||
</item>
|
||||
</channel>
|
||||
</rss>''';
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue