From 45294ade31d7dc595ed5a7502639dc875c644398 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Thu, 26 Mar 2026 17:24:59 -0400 Subject: [PATCH 1/4] feat: sync device timezone to backend on startup On shell mount, reads the IANA timezone from the device via flutter_timezone and POSTs it to PUT /api/settings as user_timezone. Mirrors the web app's App.vue behaviour so briefing events and the chat route use the correct local time on Android. Co-Authored-By: Claude Sonnet 4.6 --- lib/app.dart | 26 ++++++++--- lib/data/api/settings_api.dart | 13 ++++++ lib/providers/api_client_provider.dart | 5 ++ pubspec.lock | 64 ++++++++++++++++++++++++++ pubspec.yaml | 1 + 5 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 lib/data/api/settings_api.dart diff --git a/lib/app.dart b/lib/app.dart index 135d78f..2164a41 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:flutter_timezone/flutter_timezone.dart'; + import 'core/constants.dart'; import 'core/exceptions.dart'; import 'core/theme.dart'; @@ -159,19 +161,29 @@ class _ShellState extends ConsumerState<_Shell> { @override void initState() { super.initState(); - // Silent update check — only if we haven't already checked this session. - // Skipping when status is not idle/error prevents the dialog from - // re-appearing every time the shell re-mounts (e.g. after visiting settings). WidgetsBinding.instance.addPostFrameCallback((_) { + // Silent update check — only if we haven't already checked this session. final repoUrl = ref.read(forgejoRepoUrlProvider); - if (repoUrl == null || repoUrl.isEmpty) return; - final status = ref.read(updateProvider).status; - if (status == UpdateStatus.idle || status == UpdateStatus.error) { - ref.read(updateProvider.notifier).check(repoUrl); + if (repoUrl != null && repoUrl.isNotEmpty) { + final status = ref.read(updateProvider).status; + if (status == UpdateStatus.idle || status == UpdateStatus.error) { + ref.read(updateProvider.notifier).check(repoUrl); + } } + // Sync device timezone to backend so briefing and chat use local time. + _syncTimezone(); }); } + Future _syncTimezone() async { + try { + final tzInfo = await FlutterTimezone.getLocalTimezone(); + await ref.read(settingsApiProvider).syncTimezone(tzInfo.identifier); + } catch (_) { + // Best-effort — failure is non-critical. + } + } + int _tabIndex(String location) { for (var i = 0; i < _tabs.length; i++) { if (location.startsWith(_tabs[i])) return i; diff --git a/lib/data/api/settings_api.dart b/lib/data/api/settings_api.dart new file mode 100644 index 0000000..8d30c5f --- /dev/null +++ b/lib/data/api/settings_api.dart @@ -0,0 +1,13 @@ +import 'package:dio/dio.dart'; + +class SettingsApi { + const SettingsApi(this._dio); + final Dio _dio; + + Future syncTimezone(String ianaTimezone) async { + await _dio.put( + '/api/settings', + data: {'user_timezone': ianaTimezone}, + ); + } +} diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart index de7be00..2aba0ce 100644 --- a/lib/providers/api_client_provider.dart +++ b/lib/providers/api_client_provider.dart @@ -10,6 +10,7 @@ import '../data/api/milestones_api.dart'; import '../data/api/notes_api.dart'; import '../data/api/projects_api.dart'; import '../data/api/quick_capture_api.dart'; +import '../data/api/settings_api.dart'; import '../data/api/tasks_api.dart'; import '../data/repositories/auth_repository.dart'; import '../data/repositories/chat_repository.dart'; @@ -85,3 +86,7 @@ final milestonesRepositoryProvider = Provider((ref) { final briefingApiProvider = Provider((ref) { return BriefingApi(ref.watch(dioProvider)); }); + +final settingsApiProvider = Provider((ref) { + return SettingsApi(ref.watch(dioProvider)); +}); diff --git a/pubspec.lock b/pubspec.lock index 308afa8..bfc5ee8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -169,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" fake_async: dependency: transitive description: @@ -299,6 +307,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_timezone: + dependency: "direct main" + description: + name: flutter_timezone + sha256: e8d63f50f2806a3a71a08697286a0369e1d8f0902961327810459871c0bb01c2 + url: "https://pub.dev" + source: hosted + version: "5.0.2" flutter_web_plugins: dependency: transitive description: flutter @@ -640,6 +656,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3830495..50c50e3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: flutter_inappwebview: ^6.1.5 flutter_markdown_plus: ^1.0.7 google_fonts: ^8.0.2 + flutter_timezone: ^5.0.2 dev_dependencies: flutter_test: From 46d042790163332ff59d3bb10f55b7d54c0d0d0e Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Thu, 26 Mar 2026 17:30:26 -0400 Subject: [PATCH 2/4] feat: weather card and RSS reactions in briefing screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parse metadata field on Message model (weather, rss_item_ids) - Add WeatherCard widget: location, current temp, condition, today hi/lo, delta from yesterday, scrollable 3-day forecast strip - BriefingScreen shows WeatherCard above the first assistant message that carries weather metadata (mirrors web BriefingView.vue behaviour) - RSS reaction buttons (👍/👎) shown below assistant messages with rss_item_ids; optimistic toggle with rollback on failure - Add postRssReaction / deleteRssReaction to BriefingApi Co-Authored-By: Claude Sonnet 4.6 --- lib/data/api/briefing_api.dart | 19 +++ lib/data/models/message.dart | 4 + lib/screens/briefing/briefing_screen.dart | 143 ++++++++++++++++- lib/widgets/weather_card.dart | 179 ++++++++++++++++++++++ 4 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 lib/widgets/weather_card.dart diff --git a/lib/data/api/briefing_api.dart b/lib/data/api/briefing_api.dart index c39df8e..e28b600 100644 --- a/lib/data/api/briefing_api.dart +++ b/lib/data/api/briefing_api.dart @@ -59,4 +59,23 @@ class BriefingApi { throw dioToApp(e); } } + + /// POST /api/briefing/rss-reactions body: {rss_item_id, reaction: "up"|"down"} + Future postRssReaction(int rssItemId, String reaction) async { + try { + await _dio.post('/api/briefing/rss-reactions', + data: {'rss_item_id': rssItemId, 'reaction': reaction}); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// DELETE /api/briefing/rss-reactions/{rssItemId} + Future deleteRssReaction(int rssItemId) async { + try { + await _dio.delete('/api/briefing/rss-reactions/$rssItemId'); + } on DioException catch (e) { + throw dioToApp(e); + } + } } diff --git a/lib/data/models/message.dart b/lib/data/models/message.dart index 3df0a21..be48d10 100644 --- a/lib/data/models/message.dart +++ b/lib/data/models/message.dart @@ -7,6 +7,7 @@ class Message { final String content; final String status; // "complete" | "generating" final DateTime? createdAt; + final Map? metadata; const Message({ this.id, @@ -15,6 +16,7 @@ class Message { required this.content, this.status = 'complete', this.createdAt, + this.metadata, }); factory Message.fromJson(Map json) => Message( @@ -26,6 +28,7 @@ class Message { createdAt: json['created_at'] != null ? DateTime.parse(json['created_at'] as String) : null, + metadata: json['metadata'] as Map?, ); Message copyWith({String? content, String? status}) => Message( @@ -35,5 +38,6 @@ class Message { content: content ?? this.content, status: status ?? this.status, createdAt: createdAt, + metadata: metadata, ); } diff --git a/lib/screens/briefing/briefing_screen.dart b/lib/screens/briefing/briefing_screen.dart index 23abd5b..40ce3e5 100644 --- a/lib/screens/briefing/briefing_screen.dart +++ b/lib/screens/briefing/briefing_screen.dart @@ -2,8 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/exceptions.dart'; +import '../../data/models/message.dart'; import '../../providers/briefing_provider.dart'; +import '../../providers/api_client_provider.dart'; import '../../widgets/chat_message_bubble.dart'; +import '../../widgets/weather_card.dart'; import 'briefing_history_screen.dart'; class BriefingScreen extends ConsumerStatefulWidget { @@ -17,6 +20,8 @@ class _BriefingScreenState extends ConsumerState { final _controller = TextEditingController(); final _scrollController = ScrollController(); bool _refreshing = false; + // rss_item_id -> 'up' | 'down' | null + final Map _reactions = {}; @override void dispose() { @@ -57,6 +62,22 @@ class _BriefingScreenState extends ConsumerState { } } + Future _handleReaction(int itemId, String reaction) async { + final current = _reactions[itemId]; + final next = current == reaction ? null : reaction; + setState(() => _reactions[itemId] = next); + final api = ref.read(briefingApiProvider); + try { + if (next == null) { + await api.deleteRssReaction(itemId); + } else { + await api.postRssReaction(itemId, reaction); + } + } catch (_) { + setState(() => _reactions[itemId] = current); + } + } + Future _refresh() async { setState(() => _refreshing = true); try { @@ -178,8 +199,14 @@ class _BriefingScreenState extends ConsumerState { horizontal: 8, vertical: 8), sliver: SliverList.builder( itemCount: conv.messages.length, - itemBuilder: (_, i) => - ChatMessageBubble(message: conv.messages[i]), + itemBuilder: (_, i) { + final msg = conv.messages[i]; + return _BriefingMessageItem( + message: msg, + reactions: _reactions, + onReaction: _handleReaction, + ); + }, ), ), ], @@ -246,6 +273,118 @@ class _BriefingScreenState extends ConsumerState { } } +/// Renders a single briefing message with optional WeatherCard above it +/// and RSS reaction buttons below it (for assistant messages with metadata). +class _BriefingMessageItem extends StatelessWidget { + final Message message; + final Map reactions; + final void Function(int itemId, String reaction) onReaction; + + const _BriefingMessageItem({ + required this.message, + required this.reactions, + required this.onReaction, + }); + + @override + Widget build(BuildContext context) { + final meta = message.metadata; + final isAssistant = message.role == MessageRole.assistant; + + // Weather: show card above when metadata.weather key is present (even if null value) + final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather'); + final weatherData = hasWeatherKey ? meta['weather'] as Map? : null; + + // RSS reactions + final rssItemIds = isAssistant && meta != null + ? (meta['rss_item_ids'] as List?)?.cast() ?? [] + : []; + + final scheme = Theme.of(context).colorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (hasWeatherKey) WeatherCard(weather: weatherData), + ChatMessageBubble(message: message), + if (rssItemIds.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 8, bottom: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: rssItemIds.asMap().entries.map((entry) { + final index = entry.key; + final itemId = entry.value; + final current = reactions[itemId]; + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Story ${index + 1}', + style: TextStyle( + fontSize: 12, + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + _ReactionButton( + emoji: '👍', + active: current == 'up', + onTap: () => onReaction(itemId, 'up'), + ), + const SizedBox(width: 4), + _ReactionButton( + emoji: '👎', + active: current == 'down', + onTap: () => onReaction(itemId, 'down'), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ); + } +} + +class _ReactionButton extends StatelessWidget { + final String emoji; + final bool active; + final VoidCallback onTap; + + const _ReactionButton({ + required this.emoji, + required this.active, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: active + ? scheme.primary.withValues(alpha: 0.12) + : Colors.transparent, + border: Border.all( + color: active ? scheme.primary : scheme.outlineVariant, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Text(emoji, style: const TextStyle(fontSize: 14)), + ), + ); + } +} + class _GradientSendButton extends StatelessWidget { final VoidCallback? onPressed; final bool isStreaming; diff --git a/lib/widgets/weather_card.dart b/lib/widgets/weather_card.dart new file mode 100644 index 0000000..d51a0f9 --- /dev/null +++ b/lib/widgets/weather_card.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; + +class WeatherCard extends StatelessWidget { + final Map? weather; + + const WeatherCard({super.key, required this.weather}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + if (weather == null) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: scheme.outlineVariant), + ), + child: Text( + 'Weather data unavailable — will retry at next slot.', + style: TextStyle( + color: scheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + fontSize: 13, + ), + ), + ); + } + + final w = weather!; + final location = w['location'] as String? ?? ''; + final currentTemp = w['current_temp']; + final condition = w['condition'] as String? ?? ''; + final todayHigh = w['today_high']; + final todayLow = w['today_low']; + final yesterdayHigh = w['yesterday_high']; + final fetchedAt = w['fetched_at'] as String?; + final forecast = (w['forecast'] as List? ?? []) + .cast>(); + + String? tempDelta; + if (todayHigh != null && yesterdayHigh != null) { + final diff = (todayHigh as num) - (yesterdayHigh as num); + if (diff.abs() < 1) { + tempDelta = 'Same as yesterday'; + } else { + final dir = diff > 0 ? 'warmer' : 'cooler'; + tempDelta = '${diff.abs().round()}° $dir than yesterday'; + } + } + + String? fetchedLabel; + if (fetchedAt != null) { + try { + final dt = DateTime.parse(fetchedAt).toLocal(); + final h = dt.hour % 12 == 0 ? 12 : dt.hour % 12; + final m = dt.minute.toString().padLeft(2, '0'); + final period = dt.hour < 12 ? 'AM' : 'PM'; + fetchedLabel = '$h:$m $period'; + } catch (_) {} + } + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: scheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: location + fetched time + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + location, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: scheme.onSurface, + ), + ), + if (fetchedLabel != null) + Text( + 'as of $fetchedLabel', + style: TextStyle( + fontSize: 12, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 6), + // Current temp + condition + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '$currentTemp°', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.w700, + color: scheme.onSurface, + height: 1, + ), + ), + const SizedBox(width: 10), + Text( + condition, + style: TextStyle( + fontSize: 14, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + // Today high/low + delta + if (todayHigh != null) ...[ + const SizedBox(height: 4), + Text( + 'Today: $todayHigh° / $todayLow°' + '${tempDelta != null ? ' · $tempDelta' : ''}', + style: TextStyle(fontSize: 13, color: scheme.onSurfaceVariant), + ), + ], + // Forecast strip + if (forecast.isNotEmpty) ...[ + const SizedBox(height: 10), + const Divider(height: 1), + const SizedBox(height: 10), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + spacing: 16, + children: forecast.map((day) { + return Column( + children: [ + Text( + day['day'] as String? ?? '', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + day['condition'] as String? ?? '', + style: TextStyle( + fontSize: 11, + color: scheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 2), + Text( + '${day['high']}° / ${day['low']}°', + style: TextStyle( + fontSize: 12, + color: scheme.onSurface, + ), + ), + ], + ); + }).toList(), + ), + ), + ], + ], + ), + ); + } +} From 9f524e158dd5c7bb06821bcbe6b2efbcba3d89b3 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Thu, 26 Mar 2026 17:36:53 -0400 Subject: [PATCH 3/4] fix: weather card spacing and sizing consistency - Consistent 8px vertical rhythm (was 6/4/10 mix) - Larger current temp (36px vs 32px) for better visual weight - Fixed 64px forecast column width prevents uneven wrapping - Forecast condition text clipped at 2 lines with ellipsis - 12px spacing around forecast divider Co-Authored-By: Claude Sonnet 4.6 --- lib/widgets/weather_card.dart | 68 +++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/lib/widgets/weather_card.dart b/lib/widgets/weather_card.dart index d51a0f9..93cf8a8 100644 --- a/lib/widgets/weather_card.dart +++ b/lib/widgets/weather_card.dart @@ -95,7 +95,7 @@ class WeatherCard extends StatelessWidget { ), ], ), - const SizedBox(height: 6), + const SizedBox(height: 8), // Current temp + condition Row( crossAxisAlignment: CrossAxisAlignment.baseline, @@ -104,7 +104,7 @@ class WeatherCard extends StatelessWidget { Text( '$currentTemp°', style: TextStyle( - fontSize: 32, + fontSize: 36, fontWeight: FontWeight.w700, color: scheme.onSurface, height: 1, @@ -122,7 +122,7 @@ class WeatherCard extends StatelessWidget { ), // Today high/low + delta if (todayHigh != null) ...[ - const SizedBox(height: 4), + const SizedBox(height: 8), Text( 'Today: $todayHigh° / $todayLow°' '${tempDelta != null ? ' · $tempDelta' : ''}', @@ -131,42 +131,48 @@ class WeatherCard extends StatelessWidget { ], // Forecast strip if (forecast.isNotEmpty) ...[ - const SizedBox(height: 10), + const SizedBox(height: 12), const Divider(height: 1), - const SizedBox(height: 10), + const SizedBox(height: 12), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( - spacing: 16, + spacing: 8, children: forecast.map((day) { - return Column( - children: [ - Text( - day['day'] as String? ?? '', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: scheme.onSurface, + return SizedBox( + width: 64, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + day['day'] as String? ?? '', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), ), - ), - const SizedBox(height: 2), - Text( - day['condition'] as String? ?? '', - style: TextStyle( - fontSize: 11, - color: scheme.onSurfaceVariant, + const SizedBox(height: 3), + Text( + day['condition'] as String? ?? '', + style: TextStyle( + fontSize: 11, + color: scheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 2), - Text( - '${day['high']}° / ${day['low']}°', - style: TextStyle( - fontSize: 12, - color: scheme.onSurface, + const SizedBox(height: 3), + Text( + '${day['high']}° / ${day['low']}°', + style: TextStyle( + fontSize: 12, + color: scheme.onSurface, + ), ), - ), - ], + ], + ), ); }).toList(), ), From 7fce19a37cd24a520e9c1424c3ae6e34b6429a11 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Thu, 26 Mar 2026 17:43:06 -0400 Subject: [PATCH 4/4] feat: silent background polling for briefing screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timer.periodic every 60s calls silentRefresh() on BriefingNotifier. silentRefresh() patches AsyncData directly — never triggers AsyncLoading — so existing content stays on screen while the fetch is in flight. WidgetsBindingObserver pauses polling when app is backgrounded. Polling is also skipped while a reply is actively streaming. Co-Authored-By: Claude Sonnet 4.6 --- lib/providers/briefing_provider.dart | 18 ++++++++++++++ lib/screens/briefing/briefing_screen.dart | 29 ++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/lib/providers/briefing_provider.dart b/lib/providers/briefing_provider.dart index 40786f1..42d76be 100644 --- a/lib/providers/briefing_provider.dart +++ b/lib/providers/briefing_provider.dart @@ -23,6 +23,24 @@ class BriefingNotifier extends AsyncNotifier { return ref.read(briefingApiProvider).getToday(); } + /// Silently fetch the latest briefing and patch state without triggering + /// AsyncLoading — existing content stays visible while the fetch is in flight. + Future silentRefresh() async { + final current = state.value; + if (current == null) return; + try { + final fresh = await ref.read(briefingApiProvider).getToday(); + final curLast = current.messages.isNotEmpty ? current.messages.last : null; + final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null; + if (fresh.messages.length != current.messages.length || + newLast?.content != curLast?.content) { + state = AsyncData(fresh); + } + } catch (_) { + // Network hiccup — silently ignore, keep existing content + } + } + /// Trigger a briefing slot (e.g. "compilation") then reload. Future refresh(String slot) async { await ref.read(briefingApiProvider).triggerSlot(slot); diff --git a/lib/screens/briefing/briefing_screen.dart b/lib/screens/briefing/briefing_screen.dart index 40ce3e5..dd36df0 100644 --- a/lib/screens/briefing/briefing_screen.dart +++ b/lib/screens/briefing/briefing_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -16,15 +18,40 @@ class BriefingScreen extends ConsumerStatefulWidget { ConsumerState createState() => _BriefingScreenState(); } -class _BriefingScreenState extends ConsumerState { +class _BriefingScreenState extends ConsumerState + with WidgetsBindingObserver { final _controller = TextEditingController(); final _scrollController = ScrollController(); bool _refreshing = false; // rss_item_id -> 'up' | 'down' | null final Map _reactions = {}; + Timer? _pollTimer; + bool _appInForeground = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _pollTimer = Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently()); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _appInForeground = state == AppLifecycleState.resumed; + } + + void _pollSilently() { + if (!_appInForeground || !mounted) return; + final isStreaming = ref.read(isBriefingStreamingProvider); + if (isStreaming) return; + ref.read(briefingProvider.notifier).silentRefresh(); + } + @override void dispose() { + _pollTimer?.cancel(); + WidgetsBinding.instance.removeObserver(this); _controller.dispose(); _scrollController.dispose(); super.dispose();