# How to Implement Voice Messaging in FlutterFlow

- Tool: FlutterFlow
- Difficulty: Beginner
- Time required: 40-50 min
- Compatibility: FlutterFlow Pro+ (code export required for custom packages)
- Last updated: March 2026

## TL;DR

Add voice messaging to FlutterFlow by recording audio with the record package in AAC format, uploading the file to Firebase Storage, saving the download URL and duration to a Firestore messages document with type 'voice', then playing back messages with just_audio. Always record in AAC/M4A — WAV files are uncompressed and a 60-second message can be 10MB or more.

## Adding Voice Messages to a FlutterFlow Chat App

Voice messaging is a high-engagement feature in any chat app, but it requires handling microphone permissions, audio recording, file compression, cloud storage, and media playback — all in sequence. In FlutterFlow, each of these steps maps to a Custom Action backed by a Dart package. This tutorial covers the complete pipeline: holding a record button to capture audio in AAC format, uploading the compressed file to Firebase Storage, writing a Firestore message document with type 'voice', and rendering a compact playback widget with a progress slider. Proper audio encoding is critical — recording in WAV format by default results in huge files that are slow to upload and costly to store.

## Before you start

- FlutterFlow project with a working chat or messaging feature backed by Firestore
- Firebase Storage configured and the Firebase SDK connected to your project
- FlutterFlow Pro plan for code export (required to add custom Dart packages)
- Basic understanding of FlutterFlow Custom Actions and Firestore document writes

## Step-by-step guide

### 1. Add the record and just_audio packages to your project

In FlutterFlow, go to Settings → Pubspec Dependencies and add two packages: record (version 5.x or later) and just_audio (version 0.9.x or later). The record package handles microphone access and audio encoding. The just_audio package provides a reliable cross-platform audio player with position streams. After adding both packages, also add the required permission entries. In your exported project's ios/Runner/Info.plist add NSMicrophoneUsageDescription. In android/app/src/main/AndroidManifest.xml add RECORD_AUDIO permission. FlutterFlow's Permissions panel (Settings → App Permissions) can add the microphone permission for you without manual manifest editing.

**Expected result:** Both packages appear in pubspec.yaml and the project builds without errors after running flutter pub get.

### 2. Create startRecording and stopRecording Custom Actions

Create two Custom Actions. The first, startRecording, initialises an AudioRecorder instance and calls recorder.start() with RecordConfig specifying encoder: AudioEncoder.aacLc and bitRate: 128000. Store the recorder instance in a static variable so stopRecording can access it. The second action, stopRecording, calls recorder.stop() which returns the file path of the recorded audio. Pass this file path back to FlutterFlow as the action's return value (a String). In your chat page, bind a hold-to-record GestureDetector button: On Long Press Start calls startRecording, On Long Press End calls stopRecording and receives the file path for the upload step.

```
// Two Custom Actions: startRecording and stopRecording
import 'package:record/record.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';

// Shared recorder instance
AudioRecorder? _recorder;

// Action 1: startRecording (no parameters, no return)
Future<void> startRecording() async {
  _recorder = AudioRecorder();
  final hasPermission = await _recorder!.hasPermission();
  if (!hasPermission) return;

  final dir = await getTemporaryDirectory();
  final filePath = '${dir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.m4a';

  await _recorder!.start(
    const RecordConfig(
      encoder: AudioEncoder.aacLc,
      bitRate: 128000,
      sampleRate: 44100,
    ),
    path: filePath,
  );
}

// Action 2: stopRecording (no parameters, returns String filePath)
Future<String> stopRecording() async {
  if (_recorder == null) return '';
  final path = await _recorder!.stop();
  _recorder = null;
  return path ?? '';
}
```

**Expected result:** Holding the record button starts microphone capture. Releasing it returns a local file path string ending in .m4a.

### 3. Upload the audio file to Firebase Storage

Create a Custom Action named uploadVoiceMessage that accepts the local file path (String) and the chat conversation ID (String) as parameters. The action uploads the file to Firebase Storage at path voice_messages/{conversationId}/{timestamp}.m4a using FirebaseStorage.instance.ref().putFile(). After the upload completes, call getDownloadURL() to retrieve the public URL. Return both the download URL (String) and the file duration in seconds (Integer) from the action. To get duration, use the just_audio AudioPlayer: load the local file, read player.duration, then dispose the player before returning.

```
// Custom Action: uploadVoiceMessage
// Parameters: filePath (String), conversationId (String)
// Returns: String (download URL)
import 'package:firebase_storage/firebase_storage.dart';
import 'package:just_audio/just_audio.dart';
import 'dart:io';

Future<String> uploadVoiceMessage(String filePath, String conversationId) async {
  if (filePath.isEmpty) return '';

  final file = File(filePath);
  final timestamp = DateTime.now().millisecondsSinceEpoch;
  final storageRef = FirebaseStorage.instance
      .ref()
      .child('voice_messages/$conversationId/$timestamp.m4a');

  final uploadTask = await storageRef.putFile(
    file,
    SettableMetadata(contentType: 'audio/mp4'),
  );

  final downloadUrl = await uploadTask.ref.getDownloadURL();
  return downloadUrl;
}
```

**Expected result:** After recording, the file uploads to Firebase Storage and a download URL is returned. You can verify the file in the Firebase Storage console.

### 4. Write the voice message to Firestore

After receiving the download URL from the upload action, create a Firestore document in your messages collection (path: conversations/{conversationId}/messages). Set the document fields: type to 'voice', audioUrl to the download URL, senderId to the current user's UID, senderName to the current user's display name, durationSeconds to the recorded duration integer, and createdAt to the server timestamp. The type field is what differentiates voice messages from regular text messages in your ListView rendering logic. In your message ListView item, add a Conditional Builder that shows a voice message playback widget when type equals 'voice' and the normal text bubble when type equals 'text'.

**Expected result:** A voice message document appears in Firestore after recording. The chat ListView shows a voice message bubble in place of a text bubble.

### 5. Build the voice message playback widget

Create a Custom Widget named VoiceMessagePlayer that accepts audioUrl (String) and durationSeconds (Integer) as parameters. Inside the widget, use just_audio's AudioPlayer to load and play the URL. Build a Row containing: a play/pause IconButton that toggles player.play() and player.pause(), a LinearProgressIndicator driven by a StreamBuilder on player.positionStream showing the playback progress, and a Text showing the current position formatted as mm:ss. Initialise the player in initState and call player.setUrl(audioUrl). Dispose the player in dispose(). Register this Custom Widget in FlutterFlow and place it inside the voice message conditional branch of your ListView item.

**Expected result:** Voice messages display a compact playback widget with a play button, progress bar, and time display. Tapping play streams the audio from Firebase Storage.

## Complete code example

File: `voice_message_player_widget.dart`

```dart
// Custom Widget: VoiceMessagePlayer
// Parameters: audioUrl (String), durationSeconds (int)
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';

class VoiceMessagePlayer extends StatefulWidget {
  final String audioUrl;
  final int durationSeconds;

  const VoiceMessagePlayer({
    Key? key,
    required this.audioUrl,
    required this.durationSeconds,
  }) : super(key: key);

  @override
  State<VoiceMessagePlayer> createState() => _VoiceMessagePlayerState();
}

class _VoiceMessagePlayerState extends State<VoiceMessagePlayer> {
  late AudioPlayer _player;
  bool _isPlaying = false;
  Duration _position = Duration.zero;

  @override
  void initState() {
    super.initState();
    _player = AudioPlayer();
    _player.setUrl(widget.audioUrl);
    _player.positionStream.listen((pos) {
      if (mounted) setState(() => _position = pos);
    });
    _player.playerStateStream.listen((state) {
      if (mounted) setState(() => _isPlaying = state.playing);
    });
  }

  @override
  void dispose() {
    _player.dispose();
    super.dispose();
  }

  String _formatDuration(Duration d) {
    final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
    final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
    return '$m:$s';
  }

  @override
  Widget build(BuildContext context) {
    final total = Duration(seconds: widget.durationSeconds);
    final progress = total.inSeconds > 0
        ? _position.inSeconds / total.inSeconds
        : 0.0;

    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
      decoration: BoxDecoration(
        color: Colors.grey[200],
        borderRadius: BorderRadius.circular(20),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          IconButton(
            icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
            onPressed: () => _isPlaying ? _player.pause() : _player.play(),
          ),
          SizedBox(
            width: 120,
            child: LinearProgressIndicator(
              value: progress.clamp(0.0, 1.0),
              backgroundColor: Colors.grey[400],
              color: Theme.of(context).primaryColor,
            ),
          ),
          const SizedBox(width: 8),
          Text(
            _formatDuration(_isPlaying ? _position : total),
            style: const TextStyle(fontSize: 12),
          ),
        ],
      ),
    );
  }
}
```

## Common mistakes

- **Recording audio in WAV format instead of AAC** — WAV is uncompressed PCM audio. A 60-second recording at 44.1kHz stereo is approximately 10MB. This makes uploads slow on mobile connections, consumes excessive Firebase Storage quota, and causes high data usage for users downloading messages. Fix: Configure the record package with AudioEncoder.aacLc at 128000 bitRate. This produces an .m4a file where a 60-second recording is under 1MB, with audio quality that is indistinguishable from WAV for voice content.
- **Not requesting microphone permission before starting the recorder** — On both iOS and Android, accessing the microphone without an explicit permission request results in a silent failure — the recording starts but captures no audio, or throws an exception that crashes the action. Fix: Call recorder.hasPermission() before recorder.start(). If it returns false, show the user a dialog explaining why the microphone is needed, then call the system permission dialog using the permission_handler package.
- **Creating a new AudioPlayer instance for every voice message in the ListView** — Each AudioPlayer instance holds system audio resources. A ListView with 20 voice messages that all initialise players in initState will allocate 20 audio sessions simultaneously, causing high memory usage and potential audio conflicts. Fix: Only initialise the AudioPlayer when the user taps play, and dispose it immediately when they stop or scroll the item off screen using the ListView's cacheExtent to trigger dispose callbacks.

## Best practices

- Always record in AAC-LC format at 128kbps — never WAV — for voice messages
- Set a maximum recording duration of 5 minutes and show a timer so users know how long they have been recording
- Store durationSeconds in Firestore so the playback widget can show the total length before the user presses play
- Use Firebase Storage security rules to restrict audio file reads to authenticated users in the same conversation
- Clean up the local temporary .m4a file after a successful upload using File(path).delete()
- Show an upload progress indicator (0–100%) during the Firebase Storage upload for large recordings
- Test microphone permissions on both iOS and Android — the permission flow differs between the two platforms

## Frequently asked questions

### Does the record package work on both iOS and Android?

Yes. The record package version 5.x supports iOS, Android, macOS, Windows, Linux, and web. The AudioEncoder.aacLc encoder is available on all mobile platforms. On web, use AudioEncoder.opus instead, as AAC-LC is not supported in all browsers.

### Can users listen to voice messages without downloading the whole file first?

Yes. The just_audio package streams audio from the URL progressively, so playback begins within a second or two even for longer recordings. Firebase Storage download URLs support HTTP range requests, which enables this streaming behaviour.

### How do I show a waveform visualization for voice messages?

A real waveform requires analyzing the audio file's amplitude data, which needs a native plugin. For a simpler visual effect that looks similar, use a Row of small Container widgets with varying heights generated from a seeded random number based on the message ID. This produces a consistent fake waveform that looks good without the processing overhead.

### How do I stop playback when the user navigates away from the chat page?

Call player.stop() in the Custom Widget's dispose() method. Flutter calls dispose() automatically when the widget is removed from the widget tree, which happens when the user navigates away. Make sure your Custom Widget properly overrides dispose and does not use late initialisation that could cause null pointer exceptions during cleanup.

### What Firebase Storage security rules should I use for voice message files?

Restrict reads to authenticated users: allow read: if request.auth != null. For finer control, store conversation membership in Firestore and check it in your rules using get() to verify the requesting user is a member of the conversation before allowing the download.

### Is there a size limit for files uploaded to Firebase Storage?

Firebase Storage has no enforced file size limit for uploads via the SDK. However, the free Spark plan has a total storage limit of 5GB. For voice messages, AAC-encoded audio is so compact that even heavy users will rarely exceed a few hundred megabytes of total storage.

---

Source: https://www.rapidevelopers.com/flutterflow-tutorials/how-to-implement-voice-messaging-in-flutterflow
© RapidDev — https://www.rapidevelopers.com/flutterflow-tutorials/how-to-implement-voice-messaging-in-flutterflow
