You dictate "move the Thursday delivery to next Tuesday and let the shop know", and the app saves a task due this Tuesday with no assignee. Transcription was accurate. The extraction was wrong, and the task already exists as a record.
A better model does not fix that, because a wrong extraction and a correct one look identical until somebody reads them. What fixes it is an explicit review state: keep the extracted fields on the note as suggestions, and let a single confirm action be the only code path in your app that creates a task. The rest of this article builds that flow as six named states, shows which transitions can fail, and ends with six checks that prove nothing is written early.
Everything here targets record 7.1.2 with Cloud Storage for Firebase and Cloud Functions, running on a physical device. The Dart is a reference implementation checked against the documented APIs of those packages. It was not executed against a live project while this was written, so treat the verification section as the part that proves it in your project.
What has to exist before the first recorder call
Three pieces of platform configuration, a signed-in user, and a server you control. Miss any of the configuration and the recorder fails on a real device rather than at compile time.
| Platform | Required configuration | Minimum version |
|---|---|---|
| Android | android.permission.RECORD_AUDIO in the manifest | SDK 23, and 29 for Opus |
| iOS | NSMicrophoneUsageDescription in ios/Runner/Info.plist | 12.0 |
| macOS | The same plist key, plus the audio input capability or com.apple.security.device.audio-input | 10.15 |
The package's Android platform README lists android.permission.RECORD_AUDIO as the core permission and marks android.permission.MODIFY_AUDIO_SETTINGS as optional, for Bluetooth telephony devices such as headsets. The record package page supplies the Apple plist key, the macOS entitlement and the minimum versions above, checked at version 7.1.2 in October 2026. You also need path_provider for a writable directory, and the Firebase packages for auth, Storage and Firestore.
Format choice is a platform question before it is a quality question. The Flutter cookbook recipe for recording audio states that "the record package supports various encoders and file formats, but support varies by platform", and the package's own table lists wav and pcm16bits across the platforms it supports while marking several web cells with a question mark, which the author attributes to their own testing rather than verified support. Opus requires Android 29, and Opus inside a CAF container is "playable only on iOS platforms". Pick a format every platform you ship on supports and your transcription provider accepts, then leave it alone.
Six states, and the one that writes nothing
Name the states in your code. A boolean called isRecording cannot express the difference between a note that is uploading and a note whose transcription failed, so the UI starts guessing and the write path loses its gate.
| State | Entered when | Failure to handle |
|---|---|---|
| idle | Screen opens, or a note is finished | None |
| recording | Permission granted and start returned | Interruption, denied permission |
| stopped | stop returned a file path | A null path |
| uploading | Upload task created | Cancel, timeout, rules rejection |
| transcribing | Server accepted the audio | Provider error, audio too long |
| review | Suggestions are on the note | Nothing. This state writes no task |
The review state holds a complete set of extracted fields and creates nothing. Every other state exists to get there without losing the recording.
enum NoteStage { idle, recording, stopped, uploading, transcribing, review }
class VoiceNoteController extends ChangeNotifier {
final AudioRecorder _recorder = AudioRecorder();
NoteStage stage = NoteStage.idle;
String? noteId;
String? localPath;
String? error;
double progress = 0;
Future<void> startRecording() async {
if (!await _recorder.hasPermission()) {
error = 'microphone-denied';
notifyListeners();
return;
}
final uid = FirebaseAuth.instance.currentUser!.uid;
final notes = FirebaseFirestore.instance.collection('voiceNotes');
noteId = notes.doc().id;
await notes.doc(noteId).set({
'ownerId': uid,
'stage': 'recording',
'createdAt': FieldValue.serverTimestamp(),
});
final dir = await getApplicationDocumentsDirectory();
localPath = '${dir.path}/$noteId.wav';
await _recorder.start(
RecordConfig(
encoder: AudioEncoder.wav,
sampleRate: 16000,
numChannels: 1,
),
path: localPath!,
);
stage = NoteStage.recording;
notifyListeners();
}
@override
void dispose() {
_recorder.dispose();
super.dispose();
}
}
Two details in there matter more than they look. The note id and its document are created before the recording starts, so every later step has one key to attach audio, suggestions and errors to, and an interrupted note is already a row you can find. And the recorder is a field that gets disposed, because the cookbook's recipe calls dispose "to release its resources", and a recorder created inside build would leak one instance per rebuild.
Permission is a branch, and the default call can prompt
Check the permission before every recording and treat a false result as a UI state rather than an early return. The Flutter cookbook shows the check as if (await recorder.hasPermission()) without saying whether the call prompts. The API reference for hasPermission resolves it: the signature is Future<bool> hasPermission({bool request = true}), the method "checks and optionally requests for audio record permission", and request controls "whether to request permission if not already granted".
So the default call can show the system prompt, and a false return can mean two different things about your user. They have just declined, or they declined some time ago and the operating system will not ask again. The boolean alone cannot tell those apart, which is why a useful message names the system settings path instead of silently repeating a request that will never appear. Pass request: false when you only want to read the current state, for example to decide whether to show an explanation screen first.
Handle an interruption as a state change
A call arrives mid-sentence, or the person switches apps to check the date they are about to dictate. The recorder exposes pause() and resume() alongside stop(), and the decision worth making in advance is which one your app calls when its lifecycle changes.
Pausing on background and resuming on return keeps one file. Stopping on background gives you a shorter note that is definitely complete. Either is defensible. What breaks a flow is doing neither, because then the audio file and the UI disagree about whether a recording is still running.
Whichever you choose, keep the partial note. The file on disk and the Firestore document both already exist, so an interrupted recording should land in stopped with whatever audio it captured rather than being discarded on the way out. Someone who dictated forty seconds of a sixty second note would rather review forty seconds than start again.
Watch the upload through its snapshot events
An upload task reports progress and terminal state through snapshotEvents. Each TaskSnapshot carries state, bytesTransferred and totalBytes, and the Firebase upload documentation for Flutter lists the TaskState values as running, paused, success, canceled and error.
Future<void> uploadNote() async {
final uid = FirebaseAuth.instance.currentUser!.uid;
final ref = FirebaseStorage.instance.ref('voiceNotes/$uid/$noteId.wav');
final task = ref.putFile(File(localPath!));
stage = NoteStage.uploading;
notifyListeners();
task.snapshotEvents.listen((snapshot) {
switch (snapshot.state) {
case TaskState.running:
progress = snapshot.bytesTransferred / snapshot.totalBytes;
break;
case TaskState.paused:
break;
case TaskState.success:
stage = NoteStage.transcribing;
_requestTranscription();
break;
case TaskState.canceled:
case TaskState.error:
error = 'upload-failed';
stage = NoteStage.stopped;
break;
}
notifyListeners();
});
}
The documentation names five causes for an upload error: a local file that does not exist, insufficient permission, network timeouts, authorization failures, and cancelling the task, and it notes that cancelling "causes the upload to fail with an error that indicates it was canceled". That list splits into two different messages for your user. Permission and authorization problems are yours to fix in rules or sign-in. A timeout is theirs to retry.
Put that retry on a button. The same page says nothing about upload state surviving an app restart, so avoid a flow that assumes a half finished upload picks itself up after a relaunch. Returning the note to stopped with its local file intact gives you a retry that works either way.
Two smaller facts from the same page save a confusing afternoon later. putFile infers the content type from the file extension and otherwise uses application/octet-stream, which your provider may reject. And you cannot upload with a reference that points at the root of the bucket.
Where the transcription call belongs

On your server, for two independent reasons. The provider key is one, and the documented audio length limits are the other.
Google's Speech-to-Text documentation for transcribing long audio files, checked in October 2026, states that you "use asynchronous speech recognition to transcribe audio that is longer than 60 seconds", that "the audio time limit for local files is 60 seconds", and that for longer audio "you must have your data saved in a Cloud Storage bucket". The same page puts the asynchronous ceiling at 480 minutes and says results "remain available for retrieval for 5 days (120 hours)". A gcloud alpha reference page describes an 80 minute limit for its own command, so recheck the current quotas for the API, edition and region you use rather than trusting either figure permanently.
Those limits line up with the flow above. The audio is already in a bucket because you uploaded it there, which is where the asynchronous method needs it, and the client never has to decide whether a note crossed the sixty second line.
The key is the simpler argument. Anything shipped inside your app binary is readable by whoever installs it, so a provider key on the device is a published key. A callable function avoids that without you writing an auth layer: Firebase's callable functions documentation states that "Firebase Authentication tokens, FCM tokens, and App Check tokens, when available, are automatically included in requests" and that "the trigger automatically deserializes the request body and validates auth tokens". Your function receives a verified caller identity, checks that the caller owns the note, calls the provider with a key only the server holds, and writes suggestions back onto the note document.
Those suggestions are the function's only output. It does not create a task, and your security rules can make that structural rather than conventional: let the tasks collection accept writes only from the owner's authenticated client, and keep the function's write path on the note document.
The review screen is the only writer

Everything above exists to make this screen possible. It renders the transcript, the extracted fields as editable inputs, and one button that writes.
Future<void> confirmTask(TaskDraft edited) async {
final uid = FirebaseAuth.instance.currentUser!.uid;
await FirebaseFirestore.instance.collection('tasks').add({
'ownerId': uid,
'title': edited.title,
'dueAt': edited.dueAt,
'assigneeId': edited.assigneeId,
'sourceNoteId': noteId,
'createdAt': FieldValue.serverTimestamp(),
});
stage = NoteStage.idle;
notifyListeners();
}
Three design decisions here are worth stating plainly, because no API forces any of them. Extracted dates are pre-filled and editable rather than shown as settled, since a wrong date and a right date look the same on screen. An assignee is left empty unless the extraction matched a real contact, because a plausible wrong recipient does more damage than a blank field. And sourceNoteId stays on the task, so somebody who finds a strange task next week can hear what they actually said.
The result is an unremarkable sequence. The user talks, waits, reads, edits one field, and taps confirm. Nothing lands in their data that they only discover later.
The FlutterFlow path, and where it stops
FlutterFlow covers the capture half with built-in actions and leaves the rest to you. Its audio recording documentation describes a Start Audio Recording action that names a recorder and a Stop Audio Recording action that selects that name from a dropdown, with the result captured through an Action Output Variable Name as an Audio Path value. The docs also say to request microphone permission first and to put the Start action inside the TRUE block of the permission check.
One limitation changes how you test. The same page states that "currently, audio recording is not supported in Run or Test modes due to certain limitations", so capture has to be exercised on a built app. Plan for that before you promise a demo.
The review gate itself needs no custom code. A note document, a page binding its suggestion fields to editable inputs, and a confirm button whose action creates the task document are ordinary FlutterFlow work. What pushes you toward Dart is the transcription call, which belongs in a function anyway, and any recorder behaviour the built-in actions do not expose, such as pausing. If you are weighing that boundary more broadly, the guide on when to drop from FlutterFlow to pure Flutter covers how to decide, and the overview of how a Flutter and Firebase SaaS stack fits together covers the backend choices this flow assumes.
Six checks before you trust the flow
Run these on a physical device, in this order. Each one ends with the same question about your tasks collection.
- Start a recording, then background the app or take a call. Confirm the state is paused or stopped, the partial file still exists, and no task was created.
- Deny the microphone permission, then try to record. Confirm
hasPermissionreturns false, no recorder starts, and the message tells the user where to re-enable access. - Record a note whose date is easy to get wrong, such as "next Tuesday" spoken on a Monday. Edit the suggested date on the review screen, confirm, then read the stored document. The saved value must match your edit.
- Kill the network mid-upload. Confirm the snapshot state becomes
error, the note returns to a retryable state, and the retry is a button rather than an automatic loop. - Make the transcription fail, by pointing the function at a bad key or sending an unsupported format. Confirm the audio survives, a retry is offered, and no task appears.
- Query the tasks collection after all five. Before any confirm action, it is empty.
Steps 1 through 5 tell you whether each failure keeps the note. Step 6 tells you whether the gate holds, and it is the only one that would have caught the wrong Tuesday.
Consent, retention and deletion are part of the feature
Voice is a different kind of field from the ones around it. The audio carries a voice that identifies the speaker along with whatever else was said near the microphone, and in this design it leaves the device for a third party to process. Three things follow, and none of them is legal advice.
Tell people what happens before they hold the button, in the screen rather than a policy page. Name the provider, say that audio is uploaded, and say how long you keep it. Then make retention a decision rather than a leftover: audio can be deleted as soon as a transcript exists, kept until the task is confirmed, or kept for a window the user controls. Deleting on confirm is a reasonable default, because the transcript and sourceNoteId already provide the audit trail. Whatever you pick, ship a delete action that removes the storage object and the note together, and read your provider's own documentation for how long it retains submitted audio, because that period belongs to them and not to you.
This flow does not improve accuracy. It makes inaccuracy visible while it is still cheap to fix, and extraction quality still depends on the provider, the language, the recording conditions, and whatever structure your prompt or parser imposes on the transcript afterwards.
Start with step 6. Write the query that counts tasks created without a confirm action, run it against your current build, and you will know in a minute whether you are building this gate or repairing a flow that already writes too early.
