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('<', '<') .replaceAll('>', '>') .replaceAll('&', '&') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll(''', "'") .replaceAll(RegExp('<[^>]*>'), '') .trim(); 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; } } }