Skip to main content
No backward compatibility is required. This documents the current implementation in device_apps.

Chat screen overview

The chat screen (features/chat/) follows the 3-layer architecture: the screen widget reads providers and passes state down; all logic lives in notifiers; all platform I/O goes through interfaces in domain/services/. More sections (message list, bubbles, send flow, sessions) will be added here.

Voice recording & mic permission

Problem this solved

On the first long-press of the mic button, the OS/browser permission dialog fired mid-gesture — interrupting the pointer stream and breaking the “slide to cancel” behavior. On web, a desktop machine with no microphone would show a misleading permission dialog, then immediately report “access denied”.

Architecture

The implementation follows the same platform-abstraction rule as the rest of the app (rule 7 in flutter-app.mdx):
domain/services/audio_recording_service.dart   ← AudioRecorder interface + MicPermissionStatus enum
platform/media/audio_recorder_impl.dart        ← concrete impl, wraps `record` package
platform/media/has_mic_device.dart             ← conditional export (web / stub)
application/messages/recording_notifier.dart   ← state machine, calls interface only
features/chat/widgets/input_bar/               ← presentation: dialog + input bar
No permission or hardware API is ever imported directly in application/ or features/. Everything flows through the AudioRecorder interface.

Permission flow (all platforms)

On every long-press the notifier checks two things in order:
  1. Hardware — does any audio input device exist?
  2. Permission — has the user granted mic access?
Mic permission flow

Hardware detection on web

Browsers report audio input devices via navigator.mediaDevices.enumerateDevices() without requiring permission first. The project uses a conditional export to keep dart:js_interop out of the non-web build:
FilePlatformBehaviour
has_mic_device_stub.dartAndroid, iOS, desktopAlways returns true
has_mic_device_web.dartWebProbes enumerateDevices() for audioinput kind; returns true on API error (fail-open)

Permission check vs. request

The record ^6 package exposes hasPermission({bool request}):
CallEffect
hasPermission(request: false)Silent status check — never shows the OS dialog
hasPermission(request: true)Requests permission — shows the OS/browser dialog if not yet decided
checkPermissionStatus() uses request: false; requestPermission() uses request: true. This is what allows the notifier to probe state at startup without triggering any prompt.

State carried in RecordingIdle

FieldTypeMeaning
hasMicrophoneboolFalse when hardware probe found no audio input (web only in practice)
micPermissionGrantedboolTrue once the user has granted access on any platform
previouslyDeniedboolTrue after at least one denial — the dialog shows “Open Settings” instead of “Continue”

Key files changed

FileChange
domain/services/audio_recording_service.dartAdded MicPermissionStatus enum; replaced hasPermission() with checkPermissionStatus(), requestPermission(), openPermissionSettings(), hasMicrophoneDevice()
platform/media/audio_recorder_impl.dartImplemented the four new methods; added app_settings import for settings navigation
platform/media/has_mic_device{_stub,_web}.dartNew conditional export for hardware detection
application/messages/recording_notifier.dartRenamed webPermissionGrantedmicPermissionGranted; added hasMicrophone and previouslyDenied to RecordingIdle; unified _probeHardwareAndPermission() runs on all platforms at startup
features/chat/widgets/input_bar/mic_permission_dialog.dartNew widget: in-app explanation dialog with Continue / Not Now / Open Settings actions
features/chat/widgets/input_bar/message_input_bar.dartRemoved web-only badge and onTap handler; _onLongPressStart checks hardware then permission before starting recording
core/constants/app_strings.dartReplaced web-specific strings with platform-agnostic dialog strings

Dependency added

app_settings: ^7.0.0 — opens device settings on Android and iOS for the “Open Settings” recovery path. No-op on web.

Audio message playback

Problems this solved

AudioBubble had four bugs that made playback behave incorrectly:
  1. Looping — on completion, the player called seek(Duration.zero) while playing == true. In just_audio, seeking an active player restarts it, causing infinite looping.
  2. Multiple simultaneous players — each bubble owned its own AudioPlayer with no coordination. Playing a second message left the first running.
  3. Speed chip position — the playback speed chip was rendered below the progress slider (_MetaRow), making it hard to tap during playback.
  4. No lifecycle handling — audio kept playing when the app went to background or the screen was navigated away from.

Architecture

Playback coordination lives in the application layer as a @Riverpod(keepAlive: true) provider, following rule 3 (“no business logic in widgets”) and rule 14 (“Riverpod v3 with code generation”) from flutter-app.mdx:
application/audio/active_audio_notifier.dart   ← ActiveAudioController + activeAudioProvider
features/chat/widgets/message_bubble/audio_bubble.dart  ← ConsumerStatefulWidget, reads provider

ActiveAudioController

A plain Dart class exposed as a keepAlive Riverpod provider. It holds a weak reference to the currently playing AudioPlayer and enforces one-at-a-time playback:
MethodEffect
claim(player)Pauses the previously active player (preserving its position), registers the new one as active
release(player)Clears the reference if it matches — called from dispose()
stopAll()Pauses whatever is active — available for future use (e.g. notification, call interrupt)
claim() pauses rather than stops the previous player so the listener can resume it later, matching WhatsApp behaviour.

Looping fix

// Before — seeking while playing restarts the audio
_player.seek(Duration.zero);

// After — stop() sets playing=false before the seek
_player.stop();
_player.seek(Duration.zero);

Lifecycle handling

_AudioBubbleState mixes in WidgetsBindingObserver. When the app moves to paused or inactive, the observer pauses the player if it is currently playing. Navigation away is handled automatically: ListView.builder disposes off-screen bubbles, and dispose() releases the player from ActiveAudioController.
Audio playback state flow

Speed chip

The speed chip moved from _MetaRow (below the slider) into _PlayerRow (right of the slider):
Before:  [ Play ] [ ====Slider==== ]
         [ 0:12  ][ 1.5x ][ 10:23 ][ ✓✓ ]

After:   [ Play ] [ ====Slider==== ] [ 1.5x ]
         [ 0:12                    ][ 10:23 ][ ✓✓ ]
Available speeds: 0.5×, , 1.5×, . Tapping cycles forward through the list.

Key files changed

FileChange
application/audio/active_audio_notifier.dartNew — ActiveAudioController class + activeAudioProvider (keepAlive: true)
application/audio/active_audio_notifier.g.dartGenerated — activeAudioProvider
features/chat/widgets/message_bubble/audio_bubble.dartAudioBubble converted to ConsumerStatefulWidget; looping fix; speed chip moved to player row; WidgetsBindingObserver added; speed list updated to [0.5, 1.0, 1.5, 2.0]