import 'dart:async'; import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/exceptions.dart'; import '../../data/models/message.dart'; import '../../providers/journal_provider.dart'; import '../../providers/voice_provider.dart'; import '../../widgets/chat_message_bubble.dart'; import '../../widgets/voice_mic_button.dart'; import 'journal_history_screen.dart'; class JournalScreen extends ConsumerStatefulWidget { const JournalScreen({super.key}); @override ConsumerState createState() => _JournalScreenState(); } class _JournalScreenState extends ConsumerState with WidgetsBindingObserver { final _controller = TextEditingController(); final _scrollController = ScrollController(); bool _refreshing = false; 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; if (_appInForeground && wasBackground && mounted) { ref.read(journalProvider.notifier).refreshMessages(); } } void _pollSilently() { if (!_appInForeground || !mounted) return; final isStreaming = ref.read(isJournalStreamingProvider); if (isStreaming) return; ref.read(journalProvider.notifier).silentRefresh(); } Future _pullToRefresh() async { try { await ref.read(journalProvider.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(); 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(journalProvider.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 _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(journalProvider.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(journalProvider.notifier).regeneratePrep(); } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not regenerate prep.')), ); } } finally { if (mounted) setState(() => _refreshing = false); } } @override Widget build(BuildContext context) { final journalAsync = ref.watch(journalProvider); final isStreaming = ref.watch(isJournalStreamingProvider); final voiceState = ref.watch(voiceProvider); final scheme = Theme.of(context).colorScheme; ref.listen(journalProvider, (prev, next) => _scrollToBottom()); // Feed streaming assistant content to VoiceNotifier for TTS. ref.listen(journalProvider, (prev, next) { if (!voiceState.voiceModeActive) return; final day = next.value; if (day == null || day.messages.isEmpty) return; final last = day.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('Journal', 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(LucideIcons.refreshCw), tooltip: 'Regenerate prep', onPressed: _refresh, ), PopupMenuButton( onSelected: (value) { if (value == 'history') { Navigator.of(context).push(MaterialPageRoute( builder: (_) => const JournalHistoryScreen(), )); } }, itemBuilder: (_) => const [ PopupMenuItem( value: 'history', child: Text('Past days'), ), ], ), ], ), body: journalAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (err, stack) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text("Could not load today's journal."), const SizedBox(height: 12), FilledButton.tonal( onPressed: () => ref.invalidate(journalProvider), child: const Text('Retry'), ), ], ), ), data: (day) { final isWide = MediaQuery.of(context).size.width >= 600; Widget body = Column( children: [ Expanded( child: RefreshIndicator( onRefresh: _pullToRefresh, child: CustomScrollView( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ if (day.messages.isEmpty) SliverFillRemaining( child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'No prep 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: day.messages.length, itemBuilder: (_, i) => _JournalMessageItem(message: day.messages[i]), ), ), ], ), ), ), if (isStreaming) LinearProgressIndicator( minHeight: 2, color: scheme.primary, ), 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), ), ), ), 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…' : 'Tell your journal…', 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 journal message. The daily-prep prose itself already /// covers weather ("Weather at home will reach a high of 15.9° ..."), so /// the journal screen leaves rendering to the message bubble — no separate /// weather card on top. class _JournalMessageItem extends StatelessWidget { final Message message; const _JournalMessageItem({required this.message}); @override Widget build(BuildContext context) { return ChatMessageBubble(message: message); } } 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(0xFF5B4A8A), Color(0xFF3F3560)], ), 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( LucideIcons.send, color: disabled ? scheme.onSurface.withValues(alpha: 0.38) : Colors.white, ), onPressed: onPressed, ), ); } }