-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat: persistent voice recorder across page navigation #3960
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
- Add VoiceRecorderProvider for centralized recording state management - Recording continues when navigating between pages - Refactor voice_recorder_widget to use provider instead of local state - Works on both mobile and macOS
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code Review
This pull request refactors the voice recording functionality by extracting its state management into a new VoiceRecorderProvider (a ChangeNotifier). The VoiceRecorderWidget and ChatPage are updated to consume this provider, removing local state related to voice recording, such as _showVoiceRecorder, and delegating recording, processing, and transcription logic to the provider. The review comments identify a critical architectural issue where the VoiceRecorderProvider stores UI callbacks (onTranscriptReady, onClose), which is a fragile pattern that can lead to memory leaks and crashes. The reviewer suggests removing these callbacks from the provider, advocating for the UI to react solely to provider state changes. Specifically, the processRecording method in the provider should update its state to transcribeSuccess for user review, only calling close() if the transcript is empty, and the VoiceRecorderWidget should explicitly call provider.close() when the user confirms sending a transcript.
| // Callbacks for UI integration | ||
| Function(String transcript)? _onTranscriptReady; | ||
| VoidCallback? _onClose; | ||
|
|
||
| VoiceRecorderState get state => _state; | ||
| String get transcript => _transcript; | ||
| bool get isProcessing => _isProcessing; | ||
| List<double> get audioLevels => List.unmodifiable(_audioLevels); | ||
| bool get isRecording => _state == VoiceRecorderState.recording; | ||
| bool get isActive => _state != VoiceRecorderState.idle; | ||
|
|
||
| void setCallbacks({ | ||
| Function(String transcript)? onTranscriptReady, | ||
| VoidCallback? onClose, | ||
| }) { | ||
| _onTranscriptReady = onTranscriptReady; | ||
| _onClose = onClose; | ||
| } | ||
|
|
||
| void clearCallbacks() { | ||
| _onTranscriptReady = null; | ||
| _onClose = null; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Storing UI callbacks like _onTranscriptReady and _onClose in the provider is a fragile pattern that can lead to memory leaks and crashes. If the UI widget that sets these callbacks is disposed, the captured BuildContext and other state become stale, causing errors when the provider later invokes them.
A more robust approach is to remove these callbacks from the provider. The UI should react to state changes from the provider. I'll leave related comments on other files to complete this refactoring.
| // Callbacks for UI integration | |
| Function(String transcript)? _onTranscriptReady; | |
| VoidCallback? _onClose; | |
| VoiceRecorderState get state => _state; | |
| String get transcript => _transcript; | |
| bool get isProcessing => _isProcessing; | |
| List<double> get audioLevels => List.unmodifiable(_audioLevels); | |
| bool get isRecording => _state == VoiceRecorderState.recording; | |
| bool get isActive => _state != VoiceRecorderState.idle; | |
| void setCallbacks({ | |
| Function(String transcript)? onTranscriptReady, | |
| VoidCallback? onClose, | |
| }) { | |
| _onTranscriptReady = onTranscriptReady; | |
| _onClose = onClose; | |
| } | |
| void clearCallbacks() { | |
| _onTranscriptReady = null; | |
| _onClose = null; | |
| } | |
| VoiceRecorderState get state => _state; | |
| String get transcript => _transcript; | |
| bool get isProcessing => _isProcessing; | |
| List<double> get audioLevels => List.unmodifiable(_audioLevels); | |
| bool get isRecording => _state == VoiceRecorderState.recording; | |
| bool get isActive => _state != VoiceRecorderState.idle; |
References
- When separating UI and provider logic, provider methods (e.g.,
clearFilters,removeFilter) should handle state management, while the UI layer is responsible for invoking subsequent asynchronous actions (e.g.,applyFiltersfor server-side updates) after the state has been modified.
| try { | ||
| final transcript = await transcribeVoiceMessage(audioFile); | ||
| _transcript = transcript; | ||
| _state = VoiceRecorderState.transcribeSuccess; | ||
| _isProcessing = false; | ||
| notifyListeners(); | ||
|
|
||
| if (transcript.isNotEmpty) { | ||
| _onTranscriptReady?.call(transcript); | ||
| // Auto-close after successful transcription | ||
| close(); | ||
| } | ||
| } catch (e) { | ||
| debugPrint('Error processing recording: $e'); | ||
| _state = VoiceRecorderState.transcribeFailed; | ||
| _isProcessing = false; | ||
| notifyListeners(); | ||
| AppSnackbar.showSnackbarError('Failed to transcribe audio'); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This logic bypasses the transcribeSuccess UI state, which is meant to allow the user to review the transcript. It also uses a callback that can cause lifecycle issues.
To fix this, processRecording should just update the state to transcribeSuccess. The UI will then display the transcript for review. If the transcription is empty, it's best to close the recorder UI.
try {
final transcript = await transcribeVoiceMessage(audioFile);
if (transcript.isEmpty) {
close();
return;
}
_transcript = transcript;
_state = VoiceRecorderState.transcribeSuccess;
_isProcessing = false;
notifyListeners();
} catch (e) {
debugPrint('Error processing recording: $e');
_state = VoiceRecorderState.transcribeFailed;
_isProcessing = false;
notifyListeners();
AppSnackbar.showSnackbarError('Failed to transcribe audio');
}References
- When separating UI and provider logic, provider methods (e.g.,
clearFilters,removeFilter) should handle state management, while the UI layer is responsible for invoking subsequent asynchronous actions (e.g.,applyFiltersfor server-side updates) after the state has been modified.
| } | ||
|
|
||
| notifyListeners(); | ||
| _onClose?.call(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As part of removing the callback pattern, the call to _onClose should be removed.
References
- When separating UI and provider logic, provider methods (e.g.,
clearFilters,removeFilter) should handle state management, while the UI layer is responsible for invoking subsequent asynchronous actions (e.g.,applyFiltersfor server-side updates) after the state has been modified.
| onClose: () { | ||
| setState(() { | ||
| _showVoiceRecorder = false; | ||
| }); | ||
| // Provider handles the state change | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The onClose callback is being removed as part of a refactoring to improve state management. The VoiceRecorderWidget will now handle closing itself by calling the provider directly.
References
- When separating UI and provider logic, provider methods (e.g.,
clearFilters,removeFilter) should handle state management, while the UI layer is responsible for invoking subsequent asynchronous actions (e.g.,applyFiltersfor server-side updates) after the state has been modified.
| provider.setCallbacks( | ||
| onTranscriptReady: widget.onTranscriptReady, | ||
| onClose: widget.onClose, | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Following the removal of the callback pattern from the provider, setting callbacks here is no longer necessary.
References
- When separating UI and provider logic, provider methods (e.g.,
clearFilters,removeFilter) should handle state management, while the UI layer is responsible for invoking subsequent asynchronous actions (e.g.,applyFiltersfor server-side updates) after the state has been modified.
| ), | ||
| IconButton( | ||
| icon: const Icon(Icons.send, color: Colors.white), | ||
| onPressed: () => widget.onTranscriptReady(provider.transcript), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After the user confirms the transcript by tapping 'send', the voice recorder UI should be closed. You should call provider.close() here to reset the state.
| onPressed: () => widget.onTranscriptReady(provider.transcript), | |
| onPressed: () { | |
| widget.onTranscriptReady(provider.transcript); | |
| provider.close(); | |
| }, |
References
- When separating UI and provider logic, provider methods (e.g.,
clearFilters,removeFilter) should handle state management, while the UI layer is responsible for invoking subsequent asynchronous actions (e.g.,applyFiltersfor server-side updates) after the state has been modified.
Summary
When starting speech-to-text recording on the chat page and switching to a different page, recording would stop. This PR fixes that behavior.
Changes
VoiceRecorderProviderfor centralized recording state managementvoice_recorder_widgetto use provider instead of local stateTechnical Details
The previous implementation used local widget state which was disposed when navigating away. The new implementation uses a global provider that persists across the widget tree, allowing recording to continue regardless of page navigation.