Files
buzz/mobile/lib/features/channels/compose_bar/ios_attachment_popover.dart
klopez4212 6f5ec4a731 Add mobile voice notes (#7121)
## Summary
- add iOS and Android voice-note recording and preview directly in the
mobile composer
- add waveform playback, scrubbing, speed controls, haptics, and
one-shot playback in chat
- package recordings in a canonical H.264/AAC MP4 envelope on both
platforms so existing relays accept them
- preserve the shared composer interaction and attachment-card treatment
across mobile platforms

Mobile counterpart to #6978.

## Testing
- `just ci`
- `just mobile-check`
- `just mobile-test` (2,026 tests)
- Android debug build compiled, installed, launched, and Voice note
verified in the attachment menu on Pixel 10
- signed iOS device build installed on iPhone

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
2026-09-03 08:07:04 +00:00

208 lines
6.3 KiB
Dart

part of '../compose_bar.dart';
const _nativeAttachmentPopoverChannel = MethodChannel(
'buzz/native_attachment_popover',
);
final _iosAttachmentPopoverCoordinator = _IOSAttachmentPopoverCoordinator(
_nativeAttachmentPopoverChannel,
);
class _IOSAttachmentPopoverCallbacks {
final Future<void> Function(XFile image) onCapture;
final Future<void> Function(List<XFile> photos) onChoosePhotos;
final VoidCallback onAllPhotos;
final VoidCallback onVideo;
final VoidCallback onVoiceNote;
final VoidCallback onFiles;
const _IOSAttachmentPopoverCallbacks({
required this.onCapture,
required this.onChoosePhotos,
required this.onAllPhotos,
required this.onVideo,
required this.onVoiceNote,
required this.onFiles,
});
}
class _IOSAttachmentPopoverCoordinator {
static final Object _cancelledSupportCheck = Object();
final MethodChannel _channel;
Object? _activeOwner;
_IOSAttachmentPopoverCallbacks? _callbacks;
Completer<void>? _pendingSupportCancellation;
bool _didPresent = false;
bool _handlerInstalled = false;
_IOSAttachmentPopoverCoordinator(this._channel);
Future<bool> present({
required Object owner,
required BuildContext sourceContext,
required Future<void> Function(XFile image) onCapture,
required Future<void> Function(List<XFile> photos) onChoosePhotos,
required VoidCallback onAllPhotos,
required VoidCallback onVideo,
required VoidCallback onVoiceNote,
required VoidCallback onFiles,
}) async {
if (defaultTargetPlatform != TargetPlatform.iOS) return false;
if (_activeOwner case final activeOwner?) {
if (identical(activeOwner, owner)) return true;
if (!_didPresent) _clearOwner(activeOwner);
return _didPresent;
}
final supportCancellation = Completer<void>();
_activeOwner = owner;
_pendingSupportCancellation = supportCancellation;
_callbacks = _IOSAttachmentPopoverCallbacks(
onCapture: onCapture,
onChoosePhotos: onChoosePhotos,
onAllPhotos: onAllPhotos,
onVideo: onVideo,
onVoiceNote: onVoiceNote,
onFiles: onFiles,
);
_ensureHandler();
try {
final supportResult = await Future.any<Object?>([
_channel.invokeMethod<bool>('isSupported'),
supportCancellation.future.then<Object?>((_) => _cancelledSupportCheck),
]);
if (identical(supportResult, _cancelledSupportCheck)) return true;
if (identical(_pendingSupportCancellation, supportCancellation)) {
_pendingSupportCancellation = null;
}
if (!identical(_activeOwner, owner)) return false;
final supported = supportResult == true;
if (!supported || !sourceContext.mounted) {
_clearOwner(owner);
return false;
}
final renderObject = sourceContext.findRenderObject();
if (renderObject is! RenderBox || !renderObject.hasSize) {
_clearOwner(owner);
return false;
}
final origin = renderObject.localToGlobal(Offset.zero);
_didPresent = true;
final didPresent =
await _channel.invokeMethod<bool>('present', {
'x': origin.dx,
'y': origin.dy,
'width': renderObject.size.width,
'height': renderObject.size.height,
}) ??
false;
if (!identical(_activeOwner, owner)) return false;
if (!didPresent) _clearOwner(owner);
return didPresent;
} on PlatformException {
_clearOwner(owner);
return false;
}
}
Future<void> disposeOwner(Object owner) async {
if (!identical(_activeOwner, owner)) return;
_callbacks = null;
if (!_didPresent) {
_clearOwner(owner);
return;
}
try {
await _channel.invokeMethod<void>('dismiss');
} on PlatformException {
// The native bridge is unavailable, so there is nothing left to dismiss.
} on MissingPluginException {
// The native bridge is unavailable, so there is nothing left to dismiss.
} finally {
_clearOwner(owner);
}
}
void _ensureHandler() {
if (_handlerInstalled) return;
_handlerInstalled = true;
_channel.setMethodCallHandler(_handleMethodCall);
}
Future<void> _handleMethodCall(MethodCall call) async {
final callbacks = _callbacks;
switch (call.method) {
case 'cameraCaptured':
if (call.arguments case final String path) {
final activeCallbacks = callbacks;
if (activeCallbacks != null) {
await processCapturedImage(XFile(path), activeCallbacks.onCapture);
}
}
case 'photosSelected':
final paths = (call.arguments as List<Object?>? ?? const [])
.whereType<String>()
.toList();
if (callbacks != null && paths.isNotEmpty) {
await processTemporaryImages([
for (final path in paths) XFile(path),
], callbacks.onChoosePhotos);
}
case 'pickAllPhotos':
callbacks?.onAllPhotos();
case 'pickVideo':
callbacks?.onVideo();
case 'recordVoiceNote':
callbacks?.onVoiceNote();
case 'pickFiles':
callbacks?.onFiles();
case 'dismissed':
_activeOwner = null;
_callbacks = null;
_didPresent = false;
}
}
void _clearOwner(Object owner) {
if (!identical(_activeOwner, owner)) return;
final supportCancellation = _pendingSupportCancellation;
_pendingSupportCancellation = null;
if (supportCancellation != null && !supportCancellation.isCompleted) {
supportCancellation.complete();
}
_activeOwner = null;
_callbacks = null;
_didPresent = false;
}
}
class _IOSAttachmentPopoverController {
Future<bool> present({
required BuildContext sourceContext,
required Future<void> Function(XFile image) onCapture,
required Future<void> Function(List<XFile> photos) onChoosePhotos,
required VoidCallback onAllPhotos,
required VoidCallback onVideo,
required VoidCallback onVoiceNote,
required VoidCallback onFiles,
}) => _iosAttachmentPopoverCoordinator.present(
owner: this,
sourceContext: sourceContext,
onCapture: onCapture,
onChoosePhotos: onChoosePhotos,
onAllPhotos: onAllPhotos,
onVideo: onVideo,
onVoiceNote: onVoiceNote,
onFiles: onFiles,
);
Future<void> dispose() => _iosAttachmentPopoverCoordinator.disposeOwner(this);
}