Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions lib/src/views/chat_input/chat_input.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -62,6 +66,11 @@ class ChatInput extends StatefulWidget {
final void Function(XFile file, Iterable<Attachment> 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;

Expand Down Expand Up @@ -250,11 +259,19 @@ class _ChatInputState extends State<ChatInput> {
}

Future<void> onStartRecording() async {
await _waveController.startRecording();
try {
await _waveController.startRecording();
} catch (error) {
if (mounted) widget.onRecordingError(error);
}
}

Future<void> onStopRecording() async {
await _waveController.stopRecording();
try {
await _waveController.stopRecording();
} catch (error) {
if (mounted) widget.onRecordingError(error);
}
}

Future<void> onRecordingStopped() async {
Expand Down
26 changes: 26 additions & 0 deletions lib/src/views/llm_chat_view/llm_chat_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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'.
Expand Down Expand Up @@ -237,6 +251,7 @@ class _LlmChatViewState extends State<LlmChatView>
? null
: _onCancelMessage,
onTranslateStt: _onTranslateStt,
onRecordingError: _onRecordingError,
onCancelStt:
_pendingSttResponse == null ? null : _onCancelStt,
),
Expand Down Expand Up @@ -365,6 +380,17 @@ class _LlmChatViewState extends State<LlmChatView>

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<void> _showLlmException(LlmException? error) async {
if (error == null) return;

Expand Down
75 changes: 75 additions & 0 deletions test/voice_recording_error_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
}
Loading