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 '../../widgets/news_card.dart'; import 'briefing_history_screen.dart'; import '../../providers/voice_provider.dart'; import '../../widgets/voice_mic_button.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) { final wasBackground = !_appInForeground; _appInForeground = state == AppLifecycleState.resumed; // On resume, force a message refresh. This also unfreezes a stuck // streaming state if the SSE socket died while the app was backgrounded // — silentRefresh won't do that because it guards on isStreaming. if (_appInForeground && wasBackground && mounted) { ref.read(briefingProvider.notifier).refreshMessages(); } } void _pollSilently() { if (!_appInForeground || !mounted) return; final isStreaming = ref.read(isBriefingStreamingProvider); if (isStreaming) return; ref.read(briefingProvider.notifier).silentRefresh(); } Future _pullToRefresh() async { try { await ref.read(briefingProvider.notifier).refreshMessages(); } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not refresh.')), ); } } } @override void dispose() { _pollTimer?.cancel(); WidgetsBinding.instance.removeObserver(this); _controller.dispose(); _scrollController.dispose(); ref.read(voiceProvider.notifier).exitVoiceMode(); 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 _handleDiscuss(int convId, int itemId) async { try { await ref.read(briefingProvider.notifier).discussArticle(convId, itemId); } 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 start discussion.')), ); } } } 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 _toggleVoiceMode() async { final voice = ref.read(voiceProvider); if (voice.voiceModeActive) { ref.read(voiceProvider.notifier).exitVoiceMode(); return; } await ref.read(voiceProvider.notifier).enterVoiceMode( onTranscript: (transcript) async { await ref.read(briefingProvider.notifier).sendReply(transcript); }, enableTts: true, onError: (msg) { if (mounted) { ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(msg))); } }, ); } 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 voiceState = ref.watch(voiceProvider); final scheme = Theme.of(context).colorScheme; // Scroll to bottom when messages change ref.listen(briefingProvider, (prev, next) => _scrollToBottom()); // Feed streaming assistant content to VoiceNotifier for TTS. ref.listen(briefingProvider, (prev, next) { if (!voiceState.voiceModeActive) return; final conv = next.value; if (conv == null || conv.messages.isEmpty) return; final last = conv.messages.last; if (last.role != MessageRole.assistant) return; final isComplete = last.status != 'generating'; ref .read(voiceProvider.notifier) .feedContent(last.content, isComplete: isComplete); }); 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: (err, stack) => 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) { final isWide = MediaQuery.of(context).size.width >= 600; Widget body = Column( children: [ Expanded( child: RefreshIndicator( onRefresh: _pullToRefresh, child: CustomScrollView( controller: _scrollController, // AlwaysScrollable so pull-to-refresh fires even when // the briefing is empty or shorter than the viewport. physics: const AlwaysScrollableScrollPhysics(), 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, convId: conv.id, reactions: _reactions, onReaction: _handleReaction, onDiscuss: _handleDiscuss, ); }, ), ), ], ), ), ), // Progress bar while streaming if (isStreaming) LinearProgressIndicator( minHeight: 2, color: scheme.primary, ), // Voice mode banner if (voiceState.voiceModeActive) Container( width: double.infinity, color: const Color(0xFFEF4444).withValues(alpha: 0.12), padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 6), child: const Text( '🎤 Listening… tap mic to exit voice mode', style: TextStyle( fontSize: 12, color: Color(0xFFF87171), ), ), ), // 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: InputDecoration( hintText: voiceState.voiceModeActive ? 'Listening…' : 'Reply to your briefing…', hintStyle: voiceState.voiceModeActive ? const TextStyle(fontStyle: FontStyle.italic) : null, border: const OutlineInputBorder(), isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), ), minLines: 1, maxLines: 4, textInputAction: TextInputAction.newline, enabled: !isStreaming && !voiceState.voiceModeActive, ), ), const SizedBox(width: 8), VoiceMicButton( mode: voiceState.mode, voiceModeActive: voiceState.voiceModeActive, amplitude: voiceState.amplitude, onTap: _toggleVoiceMode, ), const SizedBox(width: 6), _GradientSendButton( onPressed: (isStreaming || voiceState.voiceModeActive) ? null : _sendReply, isStreaming: isStreaming, ), ], ), ), ), ], ); if (isWide) { body = Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 700), child: body, ), ); } return body; }, ), ); } 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 int convId; final Map reactions; final void Function(int itemId, String reaction) onReaction; final void Function(int convId, int itemId) onDiscuss; const _BriefingMessageItem({ required this.message, required this.convId, required this.reactions, required this.onReaction, required this.onDiscuss, }); @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 news cards — cap at 3 final rssItemsRaw = isAssistant && meta != null ? (meta['rss_items'] as List?)?.cast>() ?? [] : >[]; final rssItems = rssItemsRaw.map(RssItemMeta.fromJson).take(3).toList(); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (hasWeatherKey) WeatherCard(weather: weatherData), ChatMessageBubble(message: message), if (rssItems.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(4, 4, 4, 4), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: rssItems.map((item) => NewsCard( item: item, reaction: reactions[item.id], onReaction: onReaction, onDiscuss: () => onDiscuss(convId, item.id), )).toList(), ), ), ], ); } } 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(0xFF7C3AED), Color(0xFF5B21B6)], ), 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, ), ); } }