import 'dart:async'; 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 { const BriefingScreen({super.key}); @override ConsumerState createState() => _BriefingScreenState(); } 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(); } void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); } }); } Future _sendReply() async { final text = _controller.text.trim(); if (text.isEmpty) return; _controller.clear(); try { await ref.read(briefingProvider.notifier).sendReply(text); } on AppException catch (e) { if (mounted) { ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(e.message))); } } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Failed to send reply.')), ); } } } 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 { await ref.read(briefingProvider.notifier).refresh('compilation'); } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not generate briefing.')), ); } } finally { if (mounted) setState(() => _refreshing = false); } } @override Widget build(BuildContext context) { final briefingAsync = ref.watch(briefingProvider); final isStreaming = ref.watch(isBriefingStreamingProvider); final scheme = Theme.of(context).colorScheme; // Scroll to bottom when messages change ref.listen(briefingProvider, (_, _) => _scrollToBottom()); return Scaffold( appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Briefing', style: Theme.of(context).textTheme.titleLarge), Text( _todayLabel(), style: Theme.of(context).textTheme.labelSmall?.copyWith( color: scheme.onSurfaceVariant, ), ), ], ), actions: [ if (_refreshing) const Padding( padding: EdgeInsets.symmetric(horizontal: 12), child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), ) else IconButton( icon: const Icon(Icons.refresh_outlined), tooltip: 'Generate briefing', onPressed: _refresh, ), PopupMenuButton( onSelected: (value) { if (value == 'history') { Navigator.of(context).push(MaterialPageRoute( builder: (_) => const BriefingHistoryScreen(), )); } }, itemBuilder: (_) => const [ PopupMenuItem( value: 'history', child: Text('View past briefings'), ), ], ), ], ), body: briefingAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (_, _) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text("Could not load today's briefing."), const SizedBox(height: 12), FilledButton.tonal( onPressed: () => ref.invalidate(briefingProvider), child: const Text('Retry'), ), ], ), ), data: (conv) { return Column( children: [ Expanded( child: CustomScrollView( controller: _scrollController, slivers: [ if (conv.messages.isEmpty) SliverFillRemaining( child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'No briefing yet today.', style: Theme.of(context) .textTheme .bodyMedium ?.copyWith(color: scheme.onSurfaceVariant), ), const SizedBox(height: 12), FilledButton.tonal( onPressed: _refresh, child: const Text('Generate now'), ), ], ), ), ) else SliverPadding( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 8), sliver: SliverList.builder( itemCount: conv.messages.length, itemBuilder: (_, i) { final msg = conv.messages[i]; return _BriefingMessageItem( message: msg, reactions: _reactions, onReaction: _handleReaction, ); }, ), ), ], ), ), // Progress bar while streaming if (isStreaming) LinearProgressIndicator( minHeight: 2, color: scheme.primary, ), // Reply bar const Divider(height: 1), SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), child: Row( children: [ Expanded( child: TextField( controller: _controller, decoration: const InputDecoration( hintText: 'Reply to your briefing…', border: OutlineInputBorder(), isDense: true, contentPadding: EdgeInsets.symmetric( horizontal: 12, vertical: 10), ), minLines: 1, maxLines: 4, textInputAction: TextInputAction.newline, enabled: !isStreaming, ), ), const SizedBox(width: 8), _GradientSendButton( onPressed: isStreaming ? null : _sendReply, isStreaming: isStreaming, ), ], ), ), ), ], ); }, ), ); } String _todayLabel() { final now = DateTime.now(); const days = [ 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday' ]; const months = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ]; return '${days[now.weekday - 1]}, ${months[now.month - 1]} ${now.day}'; } } /// 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; const _GradientSendButton({ required this.onPressed, required this.isStreaming, }); @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final disabled = onPressed == null; return DecoratedBox( decoration: BoxDecoration( gradient: disabled ? null : const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFF6366F1), Color(0xFF4F46E5)], ), color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null, borderRadius: BorderRadius.circular(10), ), child: IconButton( icon: isStreaming ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : Icon( Icons.send, color: disabled ? scheme.onSurface.withValues(alpha: 0.38) : Colors.white, ), onPressed: onPressed, ), ); } }