diff --git a/lib/src/views/chat_input/chat_input.dart b/lib/src/views/chat_input/chat_input.dart index d46b8b50..c0ca18c7 100644 --- a/lib/src/views/chat_input/chat_input.dart +++ b/lib/src/views/chat_input/chat_input.dart @@ -31,9 +31,13 @@ class ChatInput extends StatefulWidget { /// /// [onCancelMessage] and [onCancelStt] are optional callbacks for cancelling /// message submission or speech-to-text translation respectively. + /// + /// [onRecordingError] is called when starting or stopping a voice recording + /// fails. const ChatInput({ required this.onSendMessage, required this.onTranslateStt, + required this.onRecordingError, this.initialMessage, this.onCancelEdit, this.onCancelMessage, @@ -62,6 +66,11 @@ class ChatInput extends StatefulWidget { final void Function(XFile file, Iterable attachments) onTranslateStt; + /// Callback function triggered when starting or stopping a voice recording + /// fails, e.g. because microphone permission was denied or no microphone is + /// available. + final void Function(Object error) onRecordingError; + /// The initial message to populate the input field, if any. final ChatMessage? initialMessage; @@ -250,11 +259,19 @@ class _ChatInputState extends State { } Future onStartRecording() async { - await _waveController.startRecording(); + try { + await _waveController.startRecording(); + } catch (error) { + if (mounted) widget.onRecordingError(error); + } } Future onStopRecording() async { - await _waveController.stopRecording(); + try { + await _waveController.stopRecording(); + } catch (error) { + if (mounted) widget.onRecordingError(error); + } } Future onRecordingStopped() async { diff --git a/lib/src/views/llm_chat_view/llm_chat_view.dart b/lib/src/views/llm_chat_view/llm_chat_view.dart index 1b074436..85ba56cf 100644 --- a/lib/src/views/llm_chat_view/llm_chat_view.dart +++ b/lib/src/views/llm_chat_view/llm_chat_view.dart @@ -71,6 +71,10 @@ class LlmChatView extends StatefulWidget { /// - [onErrorCallback]: Optional. The action to perform when an /// error occurs during a chat operation. By default, an alert dialog is /// displayed with the error message. + /// - [onRecordingErrorCallback]: Optional. The action to perform when + /// starting or stopping a voice recording fails, e.g. because microphone + /// permission was denied. By default, a snackbar is displayed with + /// [LlmChatViewStrings.unableToRecordAudio]. /// - [cancelMessage]: Optional. The message to display when the user cancels /// a chat operation. Defaults to 'CANCEL'. /// - [errorMessage]: Optional. The message to display when an error occurs @@ -89,6 +93,7 @@ class LlmChatView extends StatefulWidget { String? welcomeMessage, this.onCancelCallback, this.onErrorCallback, + this.onRecordingErrorCallback, this.cancelMessage = 'CANCEL', this.errorMessage = 'ERROR', this.enableAttachments = true, @@ -145,6 +150,15 @@ class LlmChatView extends StatefulWidget { final void Function(BuildContext context, LlmException error)? onErrorCallback; + /// The action to perform when starting or stopping a voice recording fails, + /// e.g. because microphone permission was denied or no microphone is + /// available. The error is the one thrown by the recorder. + /// + /// By default, a snackbar is displayed with + /// [LlmChatViewStrings.unableToRecordAudio]. + final void Function(BuildContext context, Object error)? + onRecordingErrorCallback; + /// The text message to display when the user cancels a chat operation. /// /// Defaults to 'CANCEL'. @@ -237,6 +251,7 @@ class _LlmChatViewState extends State ? null : _onCancelMessage, onTranslateStt: _onTranslateStt, + onRecordingError: _onRecordingError, onCancelStt: _pendingSttResponse == null ? null : _onCancelStt, ), @@ -365,6 +380,17 @@ class _LlmChatViewState extends State void _onCancelStt() => _pendingSttResponse?.cancel(); + void _onRecordingError(Object error) { + if (widget.onRecordingErrorCallback != null) { + widget.onRecordingErrorCallback!(context, error); + } else { + AdaptiveSnackBar.show( + context, + widget.viewModel.strings.unableToRecordAudio, + ); + } + } + Future _showLlmException(LlmException? error) async { if (error == null) return; diff --git a/test/voice_recording_error_test.dart b/test/voice_recording_error_test.dart new file mode 100644 index 00000000..40fa9eb8 --- /dev/null +++ b/test/voice_recording_error_test.dart @@ -0,0 +1,75 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'smoke_test.dart' show SimpleEchoProvider; + +void main() { + const recordChannel = MethodChannel('com.llfbandit.record/messages'); + + setUp(() { + // A recorder that can't get the microphone: no permission, and starting + // fails the way it does when the user or browser denies access. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(recordChannel, (call) async { + switch (call.method) { + case 'create': + case 'dispose': + return null; + case 'hasPermission': + return false; + default: + throw PlatformException( + code: 'record', + message: 'Permission denied', + ); + } + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(recordChannel, null); + }); + + testWidgets('shows the default message when recording fails to start', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: LlmChatView(provider: SimpleEchoProvider())), + ), + ); + + await tester.tap(find.byTooltip('Record Audio')); + await tester.pumpAndSettle(); + + expect(find.text('Unable to record audio'), findsOneWidget); + }); + + testWidgets('passes the error to onRecordingErrorCallback', (tester) async { + Object? recordingError; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: LlmChatView( + provider: SimpleEchoProvider(), + onRecordingErrorCallback: + (context, error) => recordingError = error, + ), + ), + ), + ); + + await tester.tap(find.byTooltip('Record Audio')); + await tester.pumpAndSettle(); + + expect(recordingError, isNotNull); + expect(find.text('Unable to record audio'), findsNothing); + }); +}