From aba0ca6256ec0bce574880cb5535deb99cff8719 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Fri, 17 Apr 2026 14:29:05 -0400 Subject: [PATCH] feat: gate News tab and briefing news cards on server rss_enabled setting Fetch server-side settings on app launch via new serverSettingsProvider. Hide News from bottom sheet and NavigationRail when RSS is disabled. Skip news card rendering in briefing messages when disabled. Refactor tab navigation to use dynamic tabs list and route-based refresh. Co-Authored-By: Claude Opus 4.6 --- lib/app.dart | 92 ++++++++++++----------- lib/data/api/settings_api.dart | 9 +++ lib/providers/settings_provider.dart | 31 ++++++++ lib/screens/briefing/briefing_screen.dart | 15 ++-- 4 files changed, 100 insertions(+), 47 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 56f4010..afda4f0 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -186,15 +186,19 @@ class _Shell extends ConsumerStatefulWidget { } class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { - static const _tabs = [ + static const _baseTabs = [ Routes.briefing, Routes.knowledge, Routes.conversations, Routes.projects, - Routes.news, - Routes.calendar, ]; + List _tabs(bool rssEnabled) => [ + ..._baseTabs, + if (rssEnabled) Routes.news, + Routes.calendar, + ]; + // Minimum gap between app-resume refreshes to avoid hammering the server. static const _resumeCooldown = Duration(seconds: 30); DateTime? _lastResumeRefresh; @@ -251,19 +255,18 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { ref.invalidate(briefingProvider); } - /// Refresh only the provider backing the given shell tab index. - void _refreshTab(int index) { - switch (index) { - case 0: - ref.invalidate(briefingProvider); - case 1: - ref.read(knowledgeProvider.notifier).refresh(); - case 2: - ref.read(conversationsProvider.notifier).refresh(); - case 4: - ref.read(newsProvider.notifier).refresh(); - case 5: - ref.read(calendarProvider.notifier).refresh(); + /// Refresh only the provider backing the given shell tab route. + void _refreshTab(String route) { + if (route == Routes.briefing) { + ref.invalidate(briefingProvider); + } else if (route == Routes.knowledge) { + ref.read(knowledgeProvider.notifier).refresh(); + } else if (route == Routes.conversations) { + ref.read(conversationsProvider.notifier).refresh(); + } else if (route == Routes.news) { + ref.read(newsProvider.notifier).refresh(); + } else if (route == Routes.calendar) { + ref.read(calendarProvider.notifier).refresh(); } } @@ -276,9 +279,9 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { } } - int _tabIndex(String location) { - for (var i = 0; i < _tabs.length; i++) { - if (location.startsWith(_tabs[i])) return i; + int _tabIndex(String location, List tabs) { + for (var i = 0; i < tabs.length; i++) { + if (location.startsWith(tabs[i])) return i; } return 0; } @@ -298,14 +301,15 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { context.push(Routes.projects); }, ), - ListTile( - leading: const Icon(Icons.newspaper_outlined), - title: const Text('News'), - onTap: () { - Navigator.pop(context); - context.push(Routes.news); - }, - ), + if (ref.read(rssEnabledProvider)) + ListTile( + leading: const Icon(Icons.newspaper_outlined), + title: const Text('News'), + onTap: () { + Navigator.pop(context); + context.push(Routes.news); + }, + ), ListTile( leading: const Icon(Icons.calendar_month_outlined), title: const Text('Calendar'), @@ -343,12 +347,15 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { .addPostFrameCallback((_) => _showUpdateSnackbar(next)); } }); + final rssEnabled = ref.watch(rssEnabledProvider); + final tabs = _tabs(rssEnabled); final location = GoRouterState.of(context).matchedLocation; - final index = _tabIndex(location); + final index = _tabIndex(location, tabs); // Refresh the incoming tab's data when switching between shell tabs. if (_prevTabIndex != null && _prevTabIndex != index) { - WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(index)); + final route = index < tabs.length ? tabs[index] : tabs[0]; + WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(route)); } _prevTabIndex = index; @@ -366,35 +373,36 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { children: [ NavigationRail( selectedIndex: index, - onDestinationSelected: (i) => context.go(_tabs[i]), + onDestinationSelected: (i) => context.go(tabs[i]), labelType: NavigationRailLabelType.all, - destinations: const [ - NavigationRailDestination( + destinations: [ + const NavigationRailDestination( icon: Icon(Icons.wb_sunny_outlined), selectedIcon: Icon(Icons.wb_sunny), label: Text('Briefing'), ), - NavigationRailDestination( + const NavigationRailDestination( icon: Icon(Icons.menu_book_outlined), selectedIcon: Icon(Icons.menu_book), label: Text('Knowledge'), ), - NavigationRailDestination( + const NavigationRailDestination( icon: Icon(Icons.chat_bubble_outline), selectedIcon: Icon(Icons.chat_bubble), label: Text('Chat'), ), - NavigationRailDestination( + const NavigationRailDestination( icon: Icon(Icons.folder_outlined), selectedIcon: Icon(Icons.folder), label: Text('Projects'), ), - NavigationRailDestination( - icon: Icon(Icons.newspaper_outlined), - selectedIcon: Icon(Icons.newspaper), - label: Text('News'), - ), - NavigationRailDestination( + if (ref.watch(rssEnabledProvider)) + const NavigationRailDestination( + icon: Icon(Icons.newspaper_outlined), + selectedIcon: Icon(Icons.newspaper), + label: Text('News'), + ), + const NavigationRailDestination( icon: Icon(Icons.calendar_month_outlined), selectedIcon: Icon(Icons.calendar_month), label: Text('Calendar'), @@ -431,7 +439,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { if (i == 3) { _showMoreSheet(context); } else { - context.go(_tabs[i]); + context.go(tabs[i]); } }, destinations: const [ diff --git a/lib/data/api/settings_api.dart b/lib/data/api/settings_api.dart index 8d30c5f..3042786 100644 --- a/lib/data/api/settings_api.dart +++ b/lib/data/api/settings_api.dart @@ -10,4 +10,13 @@ class SettingsApi { data: {'user_timezone': ianaTimezone}, ); } + + Future> getAll() async { + final response = await _dio.get>('/api/settings'); + return response.data ?? {}; + } + + Future update(Map updates) async { + await _dio.put('/api/settings', data: updates); + } } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 38478b4..62a6682 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'api_client_provider.dart'; + const _kServerUrl = 'server_url'; const _kThemeMode = 'theme_mode'; const _kForgejoRepoUrl = 'forgejo_repo_url'; @@ -82,3 +84,32 @@ class ServerUrlNotifier extends Notifier { state = null; } } + +final serverSettingsProvider = + AsyncNotifierProvider>( + ServerSettingsNotifier.new); + +class ServerSettingsNotifier extends AsyncNotifier> { + @override + Future> build() async { + try { + return await ref.read(settingsApiProvider).getAll(); + } catch (_) { + return {}; + } + } + + bool get rssEnabled { + final data = state.value ?? {}; + return data['rss_enabled']?.toString().toLowerCase() == 'true'; + } + + Future refresh() async { + state = AsyncData(await ref.read(settingsApiProvider).getAll()); + } +} + +final rssEnabledProvider = Provider((ref) { + final settings = ref.watch(serverSettingsProvider).value ?? {}; + return settings['rss_enabled']?.toString().toLowerCase() == 'true'; +}); diff --git a/lib/screens/briefing/briefing_screen.dart b/lib/screens/briefing/briefing_screen.dart index 41678d0..90fcfb0 100644 --- a/lib/screens/briefing/briefing_screen.dart +++ b/lib/screens/briefing/briefing_screen.dart @@ -11,6 +11,7 @@ import '../../widgets/chat_message_bubble.dart'; import '../../widgets/weather_card.dart'; import '../../widgets/news_card.dart'; import 'briefing_history_screen.dart'; +import '../../providers/settings_provider.dart'; import '../../providers/voice_provider.dart'; import '../../widgets/voice_mic_button.dart'; @@ -313,6 +314,7 @@ class _BriefingScreenState extends ConsumerState reactions: _reactions, onReaction: _handleReaction, onDiscuss: _handleDiscuss, + rssEnabled: ref.watch(rssEnabledProvider), ); }, ), @@ -428,6 +430,7 @@ class _BriefingMessageItem extends StatelessWidget { final Map reactions; final void Function(int itemId, String reaction) onReaction; final void Function(int convId, int itemId) onDiscuss; + final bool rssEnabled; const _BriefingMessageItem({ required this.message, @@ -435,6 +438,7 @@ class _BriefingMessageItem extends StatelessWidget { required this.reactions, required this.onReaction, required this.onDiscuss, + this.rssEnabled = false, }); @override @@ -446,11 +450,12 @@ class _BriefingMessageItem extends StatelessWidget { final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather'); final weatherData = hasWeatherKey ? meta['weather'] as Map? : null; - // RSS news cards — cap at 3 - final rssItemsRaw = isAssistant && meta != null - ? (meta['rss_items'] as List?)?.cast>() ?? [] - : >[]; - final rssItems = rssItemsRaw.map(RssItemMeta.fromJson).take(3).toList(); + // RSS news cards — cap at 3 (only when RSS is enabled) + final rssItems = []; + if (rssEnabled && isAssistant && meta != null) { + final raw = (meta['rss_items'] as List?)?.cast>() ?? []; + rssItems.addAll(raw.map(RssItemMeta.fromJson).take(3)); + } return Column( crossAxisAlignment: CrossAxisAlignment.stretch,