show news icon

This commit is contained in:
otsmr 2026-07-20 19:25:45 +02:00
parent 9f8cb6f735
commit 7e3caec42e
11 changed files with 152 additions and 111 deletions

View file

@ -806,11 +806,11 @@ abstract class AppLocalizations {
/// **'Feedback submitted successfully!'** /// **'Feedback submitted successfully!'**
String get contactUsSuccess; String get contactUsSuccess;
/// No description provided for @contactUsShortcut. /// No description provided for @hideNewsIcon.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Hide Feedback Icon'** /// **'Hide News Icon'**
String get contactUsShortcut; String get hideNewsIcon;
/// No description provided for @settingsHelpTerms. /// No description provided for @settingsHelpTerms.
/// ///

View file

@ -386,7 +386,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get contactUsSuccess => 'Feedback erfolgreich übermittelt!'; String get contactUsSuccess => 'Feedback erfolgreich übermittelt!';
@override @override
String get contactUsShortcut => 'Feedback-Symbol ausblenden'; String get hideNewsIcon => 'Neuigkeiten-Symbol ausblenden';
@override @override
String get settingsHelpTerms => 'Nutzungsbedingungen'; String get settingsHelpTerms => 'Nutzungsbedingungen';

View file

@ -382,7 +382,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get contactUsSuccess => 'Feedback submitted successfully!'; String get contactUsSuccess => 'Feedback submitted successfully!';
@override @override
String get contactUsShortcut => 'Hide Feedback Icon'; String get hideNewsIcon => 'Hide News Icon';
@override @override
String get settingsHelpTerms => 'Terms of Service'; String get settingsHelpTerms => 'Terms of Service';

@ -1 +1 @@
Subproject commit bb353aa572623d20578f470d2c683ead79669ed8 Subproject commit 1727cd6d4f2c56f976883497c182b1456033b614

View file

@ -72,7 +72,7 @@ class UserData {
bool videoStabilizationEnabled = true; bool videoStabilizationEnabled = true;
@JsonKey(defaultValue: true) @JsonKey(defaultValue: true)
bool showFeedbackShortcut = true; bool showNewsShortcut = true;
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
bool showShowImagePreviewWhenSending = false; bool showShowImagePreviewWhenSending = false;

View file

@ -41,7 +41,7 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
json['automaticallyMarkEqualMediaFilesAsOpened'] as bool? ?? false json['automaticallyMarkEqualMediaFilesAsOpened'] as bool? ?? false
..videoStabilizationEnabled = ..videoStabilizationEnabled =
json['videoStabilizationEnabled'] as bool? ?? true json['videoStabilizationEnabled'] as bool? ?? true
..showFeedbackShortcut = json['showFeedbackShortcut'] as bool? ?? true ..showNewsShortcut = json['showNewsShortcut'] as bool? ?? true
..showShowImagePreviewWhenSending = ..showShowImagePreviewWhenSending =
json['showShowImagePreviewWhenSending'] as bool? ?? false json['showShowImagePreviewWhenSending'] as bool? ?? false
..startWithCameraOpen = json['startWithCameraOpen'] as bool? ?? true ..startWithCameraOpen = json['startWithCameraOpen'] as bool? ?? true
@ -142,7 +142,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'automaticallyMarkEqualMediaFilesAsOpened': 'automaticallyMarkEqualMediaFilesAsOpened':
instance.automaticallyMarkEqualMediaFilesAsOpened, instance.automaticallyMarkEqualMediaFilesAsOpened,
'videoStabilizationEnabled': instance.videoStabilizationEnabled, 'videoStabilizationEnabled': instance.videoStabilizationEnabled,
'showFeedbackShortcut': instance.showFeedbackShortcut, 'showNewsShortcut': instance.showNewsShortcut,
'showShowImagePreviewWhenSending': instance.showShowImagePreviewWhenSending, 'showShowImagePreviewWhenSending': instance.showShowImagePreviewWhenSending,
'startWithCameraOpen': instance.startWithCameraOpen, 'startWithCameraOpen': instance.startWithCameraOpen,
'preSelectedEmojies': instance.preSelectedEmojies, 'preSelectedEmojies': instance.preSelectedEmojies,

View file

@ -18,13 +18,15 @@ class BlogEntry {
}); });
factory BlogEntry.fromJson(Map<String, dynamic> json) => BlogEntry( factory BlogEntry.fromJson(Map<String, dynamic> json) => BlogEntry(
title: (json['title'] as String?) ?? '', title: (json['title'] as String?) ?? '',
link: (json['link'] as String?) ?? '', link: (json['link'] as String?) ?? '',
guid: (json['guid'] as String?) ?? '', guid: (json['guid'] as String?) ?? '',
description: (json['description'] as String?) ?? '', description: (json['description'] as String?) ?? '',
imageUrl: (json['imageUrl'] as String?) ?? '', imageUrl: (json['imageUrl'] as String?) ?? '',
pubDate: json['pubDate'] != null ? DateTime.tryParse(json['pubDate'] as String) : null, pubDate: json['pubDate'] != null
); ? DateTime.tryParse(json['pubDate'] as String)
: null,
);
final String title; final String title;
final String link; final String link;
@ -34,13 +36,13 @@ class BlogEntry {
final DateTime? pubDate; final DateTime? pubDate;
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'title': title, 'title': title,
'link': link, 'link': link,
'guid': guid, 'guid': guid,
'description': description, 'description': description,
'imageUrl': imageUrl, 'imageUrl': imageUrl,
'pubDate': pubDate?.toIso8601String(), 'pubDate': pubDate?.toIso8601String(),
}; };
@override @override
String toString() { String toString() {
@ -56,7 +58,8 @@ class NewsService {
final ValueNotifier<int> unreadCountNotifier = ValueNotifier<int>(0); final ValueNotifier<int> unreadCountNotifier = ValueNotifier<int>(0);
File get _cacheFile => File(join(AppEnvironment.supportDir, 'news_feed.json')); File get _cacheFile =>
File(join(AppEnvironment.supportDir, 'news_feed.json'));
Future<void> init() async { Future<void> init() async {
try { try {
@ -152,7 +155,9 @@ class NewsService {
_updateUnreadCount(); _updateUnreadCount();
await saveCache(); await saveCache();
Log.info('Successfully fetched ${entries.length} news entries. Unread count: ${unreadCountNotifier.value}'); Log.info(
'Successfully fetched ${entries.length} news entries. Unread count: ${unreadCountNotifier.value}',
);
} catch (e) { } catch (e) {
Log.error('Failed to fetch news feed: $e'); Log.error('Failed to fetch news feed: $e');
} }
@ -166,32 +171,54 @@ class NewsService {
for (final match in matches) { for (final match in matches) {
final itemContent = match.group(1) ?? ''; final itemContent = match.group(1) ?? '';
final titleMatch = RegExp(r'<title>([\s\S]*?)<\/title>').firstMatch(itemContent); final titleMatch = RegExp(
r'<title>([\s\S]*?)<\/title>',
).firstMatch(itemContent);
final title = _stripCdata(titleMatch?.group(1) ?? ''); final title = _stripCdata(titleMatch?.group(1) ?? '');
final linkMatch = RegExp(r'<link>([\s\S]*?)<\/link>').firstMatch(itemContent); final linkMatch = RegExp(
r'<link>([\s\S]*?)<\/link>',
).firstMatch(itemContent);
final link = _stripCdata(linkMatch?.group(1) ?? ''); final link = _stripCdata(linkMatch?.group(1) ?? '');
final guidMatch = RegExp(r'<guid[^>]*?>([\s\S]*?)<\/guid>').firstMatch(itemContent); final guidMatch = RegExp(
r'<guid[^>]*?>([\s\S]*?)<\/guid>',
).firstMatch(itemContent);
final guid = _stripCdata(guidMatch?.group(1) ?? link); final guid = _stripCdata(guidMatch?.group(1) ?? link);
final descMatch = RegExp(r'<description>([\s\S]*?)<\/description>').firstMatch(itemContent); final descMatch = RegExp(
r'<description>([\s\S]*?)<\/description>',
).firstMatch(itemContent);
final descriptionRaw = _stripCdata(descMatch?.group(1) ?? ''); final descriptionRaw = _stripCdata(descMatch?.group(1) ?? '');
final description = descriptionRaw.replaceAll(RegExp('<[^>]*>'), ''); final description = descriptionRaw
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&apos;', "'")
.replaceAll('&#39;', "'")
.replaceAll(RegExp('<[^>]*>'), '')
.trim();
var imageUrl = ''; var imageUrl = '';
final enclosureMatch = RegExp(r'<enclosure\s+[^>]*?url="([^"]+)"').firstMatch(itemContent); final enclosureMatch = RegExp(
r'<enclosure\s+[^>]*?url="([^"]+)"',
).firstMatch(itemContent);
if (enclosureMatch != null) { if (enclosureMatch != null) {
imageUrl = enclosureMatch.group(1) ?? ''; imageUrl = enclosureMatch.group(1) ?? '';
} }
if (imageUrl.isEmpty) { if (imageUrl.isEmpty) {
final mediaMatch = RegExp(r'<media:content\s+[^>]*?url="([^"]+)"').firstMatch(itemContent); final mediaMatch = RegExp(
r'<media:content\s+[^>]*?url="([^"]+)"',
).firstMatch(itemContent);
if (mediaMatch != null) { if (mediaMatch != null) {
imageUrl = mediaMatch.group(1) ?? ''; imageUrl = mediaMatch.group(1) ?? '';
} }
} }
final pubDateMatch = RegExp(r'<pubDate>([\s\S]*?)<\/pubDate>').firstMatch(itemContent); final pubDateMatch = RegExp(
r'<pubDate>([\s\S]*?)<\/pubDate>',
).firstMatch(itemContent);
DateTime? pubDate; DateTime? pubDate;
if (pubDateMatch != null) { if (pubDateMatch != null) {
final pubDateStr = pubDateMatch.group(1) ?? ''; final pubDateStr = pubDateMatch.group(1) ?? '';
@ -199,14 +226,16 @@ class NewsService {
pubDate ??= _parseRfc2822(pubDateStr); pubDate ??= _parseRfc2822(pubDateStr);
} }
entries.add(BlogEntry( entries.add(
title: title, BlogEntry(
link: link, title: title,
guid: guid, link: link,
description: description, guid: guid,
imageUrl: imageUrl, description: description,
pubDate: pubDate, imageUrl: imageUrl,
)); pubDate: pubDate,
),
);
} }
return entries; return entries;
@ -238,14 +267,30 @@ class NewsService {
final timeParts = parts[3].split(':'); final timeParts = parts[3].split(':');
final hour = timeParts.isNotEmpty ? (int.tryParse(timeParts[0]) ?? 0) : 0; final hour = timeParts.isNotEmpty ? (int.tryParse(timeParts[0]) ?? 0) : 0;
final minute = timeParts.length > 1 ? (int.tryParse(timeParts[1]) ?? 0) : 0; final minute = timeParts.length > 1
final second = timeParts.length > 2 ? (int.tryParse(timeParts[2]) ?? 0) : 0; ? (int.tryParse(timeParts[1]) ?? 0)
: 0;
final second = timeParts.length > 2
? (int.tryParse(timeParts[2]) ?? 0)
: 0;
const months = { const months = {
'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6, 'jan': 1,
'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12 '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 monthStringPart = monthStr.length > 3
? monthStr.substring(0, 3)
: monthStr;
final month = months[monthStringPart] ?? 1; final month = months[monthStringPart] ?? 1;
return DateTime.utc(year, month, day, hour, minute, second); return DateTime.utc(year, month, day, hour, minute, second);

View file

@ -1,28 +0,0 @@
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';
class FeedbackIconButtonComp extends StatelessWidget {
const FeedbackIconButtonComp({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder(
stream: userService.onUserUpdated,
builder: (context, asyncSnapshot) {
if (!userService.currentUser.showFeedbackShortcut) {
return const SizedBox.shrink();
}
return IconButton(
onPressed: () => context.push(Routes.settingsHelpContactUs),
color: Colors.grey,
tooltip: context.lang.feedbackTooltip,
icon: const FaIcon(FontAwesomeIcons.commentDots, size: 19),
);
},
);
}
}

View file

@ -5,23 +5,34 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/notification_badge.comp.dart'; import 'package:twonly/src/visual/components/notification_badge.comp.dart';
import 'package:twonly/src/visual/themes/light.dart';
class NewsIconButtonComp extends StatelessWidget { class NewsIconButtonComp extends StatelessWidget {
const NewsIconButtonComp({super.key}); const NewsIconButtonComp({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ValueListenableBuilder<int>( return StreamBuilder(
valueListenable: newsService.unreadCountNotifier, stream: userService.onUserUpdated,
builder: (context, count, child) { builder: (context, snapshot) {
return NotificationBadgeComp( if (!userService.currentUser.showNewsShortcut) {
count: count.toString(), return const SizedBox.shrink();
child: IconButton( }
onPressed: () => context.push(Routes.settingsHelpNews), return ValueListenableBuilder<int>(
color: Colors.grey, valueListenable: newsService.unreadCountNotifier,
tooltip: context.lang.settingsHelpNews, builder: (context, count, child) {
icon: const FaIcon(FontAwesomeIcons.newspaper, size: 19), return NotificationBadgeComp(
), count: count.toString(),
backgroundColor: primaryColor,
textColor: Colors.black87,
child: IconButton(
onPressed: () => context.push(Routes.settingsHelpNews),
color: Colors.grey,
tooltip: context.lang.settingsHelpNews,
icon: const FaIcon(FontAwesomeIcons.bullhorn, size: 19),
),
);
},
); );
}, },
); );

View file

@ -72,9 +72,9 @@ class _AppearanceViewState extends State<AppearanceView> {
} }
} }
Future<void> toggleShowFeedbackIcon() async { Future<void> toggleShowNewsIcon() async {
await UserService.update((u) { await UserService.update((u) {
u.showFeedbackShortcut = !u.showFeedbackShortcut; u.showNewsShortcut = !u.showNewsShortcut;
}); });
} }
@ -113,11 +113,11 @@ class _AppearanceViewState extends State<AppearanceView> {
}, },
), ),
ListTile( ListTile(
title: Text(context.lang.contactUsShortcut), title: Text(context.lang.hideNewsIcon),
onTap: toggleShowFeedbackIcon, onTap: toggleShowNewsIcon,
trailing: Switch.adaptive( trailing: Switch.adaptive(
value: !userService.currentUser.showFeedbackShortcut, value: !userService.currentUser.showNewsShortcut,
onChanged: (a) => toggleShowFeedbackIcon(), onChanged: (a) => toggleShowNewsIcon(),
), ),
), ),
ListTile( ListTile(

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/reactive_tap_feedback.element.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
class NewsView extends StatefulWidget { class NewsView extends StatefulWidget {
@ -33,8 +34,8 @@ class _NewsViewState extends State<NewsView> {
child: Text( child: Text(
'No news articles found.', 'No news articles found.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.grey, color: Colors.grey,
), ),
), ),
) )
: ListView.builder( : ListView.builder(
@ -42,17 +43,23 @@ class _NewsViewState extends State<NewsView> {
itemCount: entries.length, itemCount: entries.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final entry = entries[index]; final entry = entries[index];
return Card( final isDark = isDarkMode(context);
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder( return ReactiveTapFeedback(
borderRadius: BorderRadius.circular(16), onTap: () => launchUrl(
Uri.parse(entry.link),
mode: LaunchMode.externalApplication,
), ),
elevation: 2, child: Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), color: isDark ? Colors.grey[800] : Colors.grey[200],
child: InkWell( clipBehavior: Clip.antiAlias,
onTap: () => launchUrl( shape: RoundedRectangleBorder(
Uri.parse(entry.link), borderRadius: BorderRadius.circular(12),
mode: LaunchMode.externalApplication, ),
elevation: 0,
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -65,14 +72,15 @@ class _NewsViewState extends State<NewsView> {
placeholder: (context, url) => Container( placeholder: (context, url) => Container(
height: 180, height: 180,
color: Colors.grey.withValues(alpha: 0.1), color: Colors.grey.withValues(alpha: 0.1),
child: const Center(
child: CircularProgressIndicator(),
),
), ),
errorWidget: (context, url, error) => Container( errorWidget: (context, url, error) => Container(
height: 180, height: 180,
color: Colors.grey.withValues(alpha: 0.1), color: Colors.grey.withValues(alpha: 0.1),
child: const Icon(Icons.broken_image, size: 50, color: Colors.grey), child: const Icon(
Icons.broken_image,
size: 50,
color: Colors.grey,
),
), ),
), ),
Padding( Padding(
@ -85,7 +93,8 @@ class _NewsViewState extends State<NewsView> {
DateFormat.yMMMMd( DateFormat.yMMMMd(
Localizations.localeOf(context).toString(), Localizations.localeOf(context).toString(),
).format(entry.pubDate!), ).format(entry.pubDate!),
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Colors.grey, color: Colors.grey,
), ),
), ),
@ -93,7 +102,8 @@ class _NewsViewState extends State<NewsView> {
], ],
Text( Text(
entry.title, entry.title,
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.titleLarge
?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@ -102,8 +112,11 @@ class _NewsViewState extends State<NewsView> {
entry.description, entry.description,
maxLines: 3, maxLines: 3,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium
color: context.color.onSurface.withValues(alpha: 0.8), ?.copyWith(
color: context.color.onSurface.withValues(
alpha: 0.8,
),
), ),
), ),
], ],