fc6c9648f9
VadHandler is now the sole mic owner — removed separate AudioRecorder that caused audio focus contention on Android. Audio from onSpeechEnd is encoded as WAV for Whisper. Provider switched to autoDispose to match widget lifecycle, preventing defunct element assertions. Recording UI deferred until mic is open. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
526 lines
17 KiB
Dart
526 lines
17 KiB
Dart
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<BriefingScreen> createState() => _BriefingScreenState();
|
|
}
|
|
|
|
class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|
with WidgetsBindingObserver {
|
|
final _controller = TextEditingController();
|
|
final _scrollController = ScrollController();
|
|
bool _refreshing = false;
|
|
// rss_item_id -> 'up' | 'down' | null
|
|
final Map<int, String?> _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<void> _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();
|
|
super.dispose();
|
|
}
|
|
|
|
void _scrollToBottom() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_scrollController.hasClients) {
|
|
_scrollController.animateTo(
|
|
_scrollController.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _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<void> _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<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> _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<void> _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<String>(
|
|
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<int, String?> 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<String, dynamic>? : null;
|
|
|
|
// RSS news cards — cap at 3
|
|
final rssItemsRaw = isAssistant && meta != null
|
|
? (meta['rss_items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? []
|
|
: <Map<String, dynamic>>[];
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
}
|