feat: weather card and RSS reactions in briefing screen
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -59,4 +59,23 @@ class BriefingApi {
|
|||||||
throw dioToApp(e);
|
throw dioToApp(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// POST /api/briefing/rss-reactions body: {rss_item_id, reaction: "up"|"down"}
|
||||||
|
Future<void> 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<void> deleteRssReaction(int rssItemId) async {
|
||||||
|
try {
|
||||||
|
await _dio.delete('/api/briefing/rss-reactions/$rssItemId');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class Message {
|
|||||||
final String content;
|
final String content;
|
||||||
final String status; // "complete" | "generating"
|
final String status; // "complete" | "generating"
|
||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
|
final Map<String, dynamic>? metadata;
|
||||||
|
|
||||||
const Message({
|
const Message({
|
||||||
this.id,
|
this.id,
|
||||||
@@ -15,6 +16,7 @@ class Message {
|
|||||||
required this.content,
|
required this.content,
|
||||||
this.status = 'complete',
|
this.status = 'complete',
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
|
this.metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Message.fromJson(Map<String, dynamic> json) => Message(
|
factory Message.fromJson(Map<String, dynamic> json) => Message(
|
||||||
@@ -26,6 +28,7 @@ class Message {
|
|||||||
createdAt: json['created_at'] != null
|
createdAt: json['created_at'] != null
|
||||||
? DateTime.parse(json['created_at'] as String)
|
? DateTime.parse(json['created_at'] as String)
|
||||||
: null,
|
: null,
|
||||||
|
metadata: json['metadata'] as Map<String, dynamic>?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Message copyWith({String? content, String? status}) => Message(
|
Message copyWith({String? content, String? status}) => Message(
|
||||||
@@ -35,5 +38,6 @@ class Message {
|
|||||||
content: content ?? this.content,
|
content: content ?? this.content,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
|
metadata: metadata,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../core/exceptions.dart';
|
import '../../core/exceptions.dart';
|
||||||
|
import '../../data/models/message.dart';
|
||||||
import '../../providers/briefing_provider.dart';
|
import '../../providers/briefing_provider.dart';
|
||||||
|
import '../../providers/api_client_provider.dart';
|
||||||
import '../../widgets/chat_message_bubble.dart';
|
import '../../widgets/chat_message_bubble.dart';
|
||||||
|
import '../../widgets/weather_card.dart';
|
||||||
import 'briefing_history_screen.dart';
|
import 'briefing_history_screen.dart';
|
||||||
|
|
||||||
class BriefingScreen extends ConsumerStatefulWidget {
|
class BriefingScreen extends ConsumerStatefulWidget {
|
||||||
@@ -17,6 +20,8 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
|||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
bool _refreshing = false;
|
bool _refreshing = false;
|
||||||
|
// rss_item_id -> 'up' | 'down' | null
|
||||||
|
final Map<int, String?> _reactions = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -57,6 +62,22 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _refresh() async {
|
Future<void> _refresh() async {
|
||||||
setState(() => _refreshing = true);
|
setState(() => _refreshing = true);
|
||||||
try {
|
try {
|
||||||
@@ -178,8 +199,14 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
|||||||
horizontal: 8, vertical: 8),
|
horizontal: 8, vertical: 8),
|
||||||
sliver: SliverList.builder(
|
sliver: SliverList.builder(
|
||||||
itemCount: conv.messages.length,
|
itemCount: conv.messages.length,
|
||||||
itemBuilder: (_, i) =>
|
itemBuilder: (_, i) {
|
||||||
ChatMessageBubble(message: conv.messages[i]),
|
final msg = conv.messages[i];
|
||||||
|
return _BriefingMessageItem(
|
||||||
|
message: msg,
|
||||||
|
reactions: _reactions,
|
||||||
|
onReaction: _handleReaction,
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -246,6 +273,118 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<int, String?> 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<String, dynamic>? : null;
|
||||||
|
|
||||||
|
// RSS reactions
|
||||||
|
final rssItemIds = isAssistant && meta != null
|
||||||
|
? (meta['rss_item_ids'] as List<dynamic>?)?.cast<int>() ?? []
|
||||||
|
: <int>[];
|
||||||
|
|
||||||
|
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 {
|
class _GradientSendButton extends StatelessWidget {
|
||||||
final VoidCallback? onPressed;
|
final VoidCallback? onPressed;
|
||||||
final bool isStreaming;
|
final bool isStreaming;
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class WeatherCard extends StatelessWidget {
|
||||||
|
final Map<String, dynamic>? 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<dynamic>? ?? [])
|
||||||
|
.cast<Map<String, dynamic>>();
|
||||||
|
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user