Files
buzz/mobile/lib/features/channels/compose_bar/dock.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

207 lines
7.1 KiB
Dart

part of '../compose_bar.dart';
class _ComposerDockFrame extends HookWidget {
final Animation<double> expansionAnimation;
final bool forceFullWidth;
final Widget child;
const _ComposerDockFrame({
required this.expansionAnimation,
required this.forceFullWidth,
required this.child,
});
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final recordingTransition = useAnimationController(
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 200),
reverseDuration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 140),
initialValue: forceFullWidth ? 1 : 0,
);
useEffect(() {
if (forceFullWidth) {
recordingTransition.value = math.max(
recordingTransition.value,
expansionAnimation.value.clamp(0.0, 1.0),
);
recordingTransition.forward();
} else {
recordingTransition.reverse();
}
return null;
}, [forceFullWidth, reducedMotion]);
const compactVerticalOffset = Grid.twelve + Grid.quarter;
final visibleBottomGutter = math.max(
Grid.twelve,
MediaQuery.viewPaddingOf(context).bottom +
Grid.xxs -
compactVerticalOffset,
);
final recordingGutterDelta = visibleBottomGutter - Grid.twelve;
final backdropHeight = mobileTabFooterBackdropHeight(context);
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
key: const ValueKey('composer-footer-gradient'),
left: 0,
right: 0,
bottom: 0,
height: backdropHeight,
child: IgnorePointer(
child: MobileTabFooterBackdrop(height: backdropHeight),
),
),
Padding(
padding: EdgeInsets.only(
left: Grid.twelve,
right: Grid.twelve,
bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs,
),
child: Align(
alignment: Alignment.bottomCenter,
child: AnimatedBuilder(
animation: Listenable.merge([
expansionAnimation,
recordingTransition,
]),
child: child,
builder: (context, child) {
final widthProgress = math.max(
expansionAnimation.value.clamp(0.0, 1.0),
recordingTransition.value,
);
final expansionProgress = expansionAnimation.value.clamp(
0.0,
1.0,
);
final compactPositionProgress = 1 - expansionProgress;
return Transform.translate(
key: const ValueKey('composer-position-transition'),
offset: Offset(
0,
compactVerticalOffset * compactPositionProgress,
),
child: Padding(
key: const ValueKey('composer-recording-outer-gutter'),
padding: EdgeInsets.symmetric(
horizontal: forceFullWidth
? recordingGutterDelta * compactPositionProgress
: 0,
),
child: FractionallySizedBox(
key: const ValueKey('composer-width-transition'),
widthFactor: 0.85 + 0.15 * widthProgress,
child: child,
),
),
);
},
),
),
),
],
);
}
}
class _ComposerOverlayPortal extends StatelessWidget {
final OverlayPortalController controller;
final ValueListenable<_AttachmentSurface> attachmentSurface;
final bool reducedMotion;
final Widget Function(_AttachmentSurface surface) buildOverlayPanel;
final VoidCallback onDismissAttachmentSurface;
final Widget child;
const _ComposerOverlayPortal({
required this.controller,
required this.attachmentSurface,
required this.reducedMotion,
required this.buildOverlayPanel,
required this.onDismissAttachmentSurface,
required this.child,
});
@override
Widget build(BuildContext context) {
return OverlayPortal.overlayChildLayoutBuilder(
controller: controller,
overlayChildBuilder: (context, layoutInfo) {
final composerOrigin = MatrixUtils.transformPoint(
layoutInfo.childPaintTransform,
Offset.zero,
);
return ValueListenableBuilder<_AttachmentSurface>(
valueListenable: attachmentSurface,
builder: (context, surface, _) {
final surfaceDuration = reducedMotion
? Duration.zero
: Duration(
milliseconds:
surface == _AttachmentSurface.camera ||
surface == _AttachmentSurface.photos
? 320
: 250,
);
final expandedSurfaceCoversComposer =
surface == _AttachmentSurface.camera ||
surface == _AttachmentSurface.photos;
final surfaceLeft = expandedSurfaceCoversComposer
? Grid.twelve
: composerOrigin.dx;
final surfaceWidth = expandedSurfaceCoversComposer
? layoutInfo.overlaySize.width - (Grid.twelve * 2)
: layoutInfo.childSize.width;
final overlayAnchorY =
composerOrigin.dy +
(expandedSurfaceCoversComposer
? layoutInfo.childSize.height + Grid.twelve
: 0);
return Stack(
children: [
if (surface != _AttachmentSurface.closed)
Positioned(
left: 0,
top: 0,
right: 0,
height: composerOrigin.dy,
child: ExcludeSemantics(
child: GestureDetector(
key: const ValueKey('attachment-dismiss-barrier'),
behavior: HitTestBehavior.opaque,
onTap: onDismissAttachmentSurface,
),
),
),
AnimatedPositioned(
duration: surfaceDuration,
curve:
surface == _AttachmentSurface.camera ||
surface == _AttachmentSurface.photos
? const Cubic(0.34, 1.25, 0.64, 1)
: const Cubic(0.22, 1, 0.36, 1),
left: surfaceLeft,
bottom: layoutInfo.overlaySize.height - overlayAnchorY,
width: surfaceWidth,
child: ClipRect(
child: Padding(
padding: const EdgeInsets.only(bottom: Grid.xxs),
child: buildOverlayPanel(surface),
),
),
),
],
);
},
);
},
child: child,
);
}
}