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:
@@ -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<BriefingScreen> {
|
||||
final _controller = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
bool _refreshing = false;
|
||||
// rss_item_id -> 'up' | 'down' | null
|
||||
final Map<int, String?> _reactions = {};
|
||||
|
||||
@override
|
||||
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 {
|
||||
setState(() => _refreshing = true);
|
||||
try {
|
||||
@@ -178,8 +199,14 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
||||
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<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 {
|
||||
final VoidCallback? onPressed;
|
||||
final bool isStreaming;
|
||||
|
||||
Reference in New Issue
Block a user