From 46d042790163332ff59d3bb10f55b7d54c0d0d0e Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Thu, 26 Mar 2026 17:30:26 -0400 Subject: [PATCH] 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(), + ), + ), + ], + ], + ), + ); + } +}