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>
294 lines
10 KiB
Dart
294 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../core/exceptions.dart';
|
|
import '../../data/models/message.dart';
|
|
import '../../providers/chat_provider.dart';
|
|
import '../../providers/voice_provider.dart';
|
|
import '../../widgets/chat_message_bubble.dart';
|
|
import '../../widgets/voice_mic_button.dart';
|
|
|
|
class ChatScreen extends ConsumerStatefulWidget {
|
|
final int conversationId;
|
|
const ChatScreen({super.key, required this.conversationId});
|
|
|
|
@override
|
|
ConsumerState<ChatScreen> createState() => _ChatScreenState();
|
|
}
|
|
|
|
class _ChatScreenState extends ConsumerState<ChatScreen>
|
|
with WidgetsBindingObserver {
|
|
final _controller = TextEditingController();
|
|
final _scrollController = ScrollController();
|
|
bool _refreshing = false;
|
|
|
|
Future<void> _refreshMessages() async {
|
|
if (_refreshing) return;
|
|
setState(() => _refreshing = true);
|
|
try {
|
|
await ref
|
|
.read(messagesProvider(widget.conversationId).notifier)
|
|
.refresh();
|
|
} catch (_) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Could not refresh messages.')),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _refreshing = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
// If we land on a conversation whose last assistant message is already
|
|
// mid-stream (e.g. the /news discuss button creates a conv and
|
|
// auto-kicks generation), attach to the running stream so the user sees
|
|
// live tokens instead of a frozen placeholder.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
ref
|
|
.read(messagesProvider(widget.conversationId).notifier)
|
|
.attachToGeneration();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.resumed) {
|
|
ref.read(messagesProvider(widget.conversationId).notifier).refresh();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
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> _send() async {
|
|
final text = _controller.text.trim();
|
|
if (text.isEmpty) return;
|
|
_controller.clear();
|
|
try {
|
|
await ref
|
|
.read(messagesProvider(widget.conversationId).notifier)
|
|
.sendMessage(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 message.')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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(messagesProvider(widget.conversationId).notifier)
|
|
.sendMessage(transcript);
|
|
},
|
|
enableTts: true,
|
|
onError: (msg) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text(msg)));
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final messagesAsync = ref.watch(messagesProvider(widget.conversationId));
|
|
final isStreaming = ref.watch(isStreamingProvider(widget.conversationId));
|
|
final streamingStatus =
|
|
ref.watch(streamingStatusProvider(widget.conversationId));
|
|
final voiceState = ref.watch(voiceProvider);
|
|
|
|
// Scroll when messages change.
|
|
ref.listen(messagesProvider(widget.conversationId), (prev, next) {
|
|
_scrollToBottom();
|
|
});
|
|
|
|
// Feed streaming content to VoiceNotifier for TTS.
|
|
ref.listen(messagesProvider(widget.conversationId), (prev, next) {
|
|
if (!voiceState.voiceModeActive) return;
|
|
final messages = next.value;
|
|
if (messages == null || messages.isEmpty) return;
|
|
final last = messages.last;
|
|
if (last.role != MessageRole.assistant) return;
|
|
final isComplete = last.status != 'generating';
|
|
ref
|
|
.read(voiceProvider.notifier)
|
|
.feedContent(last.content, isComplete: isComplete);
|
|
});
|
|
|
|
final convTitle = ref
|
|
.watch(conversationsProvider)
|
|
.value
|
|
?.where((c) => c.id == widget.conversationId)
|
|
.firstOrNull
|
|
?.title;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(convTitle?.isNotEmpty == true ? convTitle! : 'Chat'),
|
|
actions: [
|
|
IconButton(
|
|
tooltip: 'Refresh',
|
|
onPressed: _refreshing ? null : _refreshMessages,
|
|
icon: _refreshing
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.refresh),
|
|
),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
Expanded(
|
|
child: messagesAsync.when(
|
|
loading: () =>
|
|
const Center(child: CircularProgressIndicator()),
|
|
error: (err, stack) => const Center(
|
|
child: Text('Could not load messages.'),
|
|
),
|
|
data: (messages) {
|
|
return RefreshIndicator(
|
|
onRefresh: _refreshMessages,
|
|
child: messages.isEmpty
|
|
? ListView(
|
|
// Needs to be scrollable for RefreshIndicator to
|
|
// fire on empty state — plain Center won't work.
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
children: const [
|
|
SizedBox(height: 240),
|
|
Center(child: Text('Send a message to start.')),
|
|
],
|
|
)
|
|
: ListView.builder(
|
|
controller: _scrollController,
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8, vertical: 12),
|
|
itemCount: messages.length,
|
|
itemBuilder: (context, i) => ChatMessageBubble(
|
|
message: messages[i],
|
|
streamingStatus: (i == messages.length - 1 &&
|
|
messages[i].status == 'generating')
|
|
? streamingStatus
|
|
: '',
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
// 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),
|
|
),
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
SafeArea(
|
|
child: Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _controller,
|
|
decoration: InputDecoration(
|
|
hintText: voiceState.voiceModeActive
|
|
? 'Listening…'
|
|
: 'Message…',
|
|
hintStyle: voiceState.voiceModeActive
|
|
? const TextStyle(fontStyle: FontStyle.italic)
|
|
: null,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 12, vertical: 10),
|
|
),
|
|
maxLines:
|
|
MediaQuery.of(context).size.width >= 600 ? 2 : 4,
|
|
minLines: 1,
|
|
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),
|
|
IconButton.filled(
|
|
onPressed: (isStreaming || voiceState.voiceModeActive)
|
|
? null
|
|
: _send,
|
|
icon: isStreaming
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child:
|
|
CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.send),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|