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.
On every long-press the notifier checks two things in order:
- Hardware — does any audio input device exist?
- Permission — has the user granted mic access?
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:
| File | Platform | Behaviour |
|---|
has_mic_device_stub.dart | Android, iOS, desktop | Always returns true |
has_mic_device_web.dart | Web | Probes enumerateDevices() for audioinput kind; returns true on API error (fail-open) |
Permission check vs. request
The record ^6 package exposes hasPermission({bool request}):
| Call | Effect |
|---|
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
| Field | Type | Meaning |
|---|
hasMicrophone | bool | False when hardware probe found no audio input (web only in practice) |
micPermissionGranted | bool | True once the user has granted access on any platform |
previouslyDenied | bool | True after at least one denial — the dialog shows “Open Settings” instead of “Continue” |
Key files changed
| File | Change |
|---|
domain/services/audio_recording_service.dart | Added MicPermissionStatus enum; replaced hasPermission() with checkPermissionStatus(), requestPermission(), openPermissionSettings(), hasMicrophoneDevice() |
platform/media/audio_recorder_impl.dart | Implemented the four new methods; added app_settings import for settings navigation |
platform/media/has_mic_device{_stub,_web}.dart | New conditional export for hardware detection |
application/messages/recording_notifier.dart | Renamed webPermissionGranted → micPermissionGranted; added hasMicrophone and previouslyDenied to RecordingIdle; unified _probeHardwareAndPermission() runs on all platforms at startup |
features/chat/widgets/input_bar/mic_permission_dialog.dart | New widget: in-app explanation dialog with Continue / Not Now / Open Settings actions |
features/chat/widgets/input_bar/message_input_bar.dart | Removed web-only badge and onTap handler; _onLongPressStart checks hardware then permission before starting recording |
core/constants/app_strings.dart | Replaced 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:
- Looping — on completion, the player called
seek(Duration.zero) while playing == true. In just_audio, seeking an active player restarts it, causing infinite looping.
- Multiple simultaneous players — each bubble owned its own
AudioPlayer with no coordination. Playing a second message left the first running.
- Speed chip position — the playback speed chip was rendered below the progress slider (
_MetaRow), making it hard to tap during playback.
- 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:
| Method | Effect |
|---|
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.
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×, 1.5×, 2×. Tapping cycles forward through the list.
Key files changed
| File | Change |
|---|
application/audio/active_audio_notifier.dart | New — ActiveAudioController class + activeAudioProvider (keepAlive: true) |
application/audio/active_audio_notifier.g.dart | Generated — activeAudioProvider |
features/chat/widgets/message_bubble/audio_bubble.dart | AudioBubble 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] |