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

198 lines
4.9 KiB
Dart

import 'dart:ui' show lerpDouble;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// Animated icon that morphs between voice-note play and pause glyphs.
class VoiceNotePlayPauseIcon extends HookWidget {
const VoiceNotePlayPauseIcon({
super.key,
required this.isPlaying,
this.color,
this.size = 23,
});
final bool isPlaying;
final Color? color;
final double size;
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final controller = useAnimationController(
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 160),
initialValue: isPlaying ? 1 : 0,
);
useEffect(() {
final target = isPlaying ? 1.0 : 0.0;
if (reducedMotion) {
controller.value = target;
} else {
controller.animateTo(
target,
duration: const Duration(milliseconds: 160),
curve: const Cubic(0.77, 0, 0.175, 1),
);
}
return null;
}, [isPlaying, reducedMotion]);
return ExcludeSemantics(
child: SizedBox.square(
dimension: size,
child: AnimatedBuilder(
animation: controller,
builder: (context, _) => CustomPaint(
key: ValueKey(
isPlaying
? 'voice-note-play-pause-icon-pause'
: 'voice-note-play-pause-icon-play',
),
painter: _VoiceNotePlayPausePainter(
progress: controller.value,
color: color ?? IconTheme.of(context).color!,
),
),
),
),
);
}
}
class _VoiceNotePlayPausePainter extends CustomPainter {
const _VoiceNotePlayPausePainter({
required this.progress,
required this.color,
});
final double progress;
final Color color;
static const _playPrimary = <Offset>[
Offset(8, 4.75),
Offset(7, 4.15),
Offset(7, 5.35),
Offset(7, 18.65),
Offset(7, 19.85),
Offset(8, 19.25),
Offset(18.1, 12.75),
Offset(19.3, 12),
Offset(18.1, 11.25),
Offset(8, 4.75),
Offset(8, 4.75),
Offset(8, 4.75),
];
static const _playSecondary = <Offset>[
Offset(12, 12),
Offset(12, 12),
Offset(12, 12),
Offset(12, 12),
Offset(12, 12),
Offset(12, 12),
Offset(12, 12),
Offset(12, 12),
Offset(12, 12),
Offset(12, 12),
Offset(12, 12),
Offset(12, 12),
];
static const _pausePrimary = <Offset>[
Offset(7.5, 5),
Offset(6.5, 5),
Offset(6.5, 6),
Offset(6.5, 18),
Offset(6.5, 19),
Offset(7.5, 19),
Offset(9.5, 19),
Offset(10.5, 19),
Offset(10.5, 18),
Offset(10.5, 6),
Offset(10.5, 5),
Offset(9.5, 5),
];
static const _pauseSecondary = <Offset>[
Offset(14.5, 5),
Offset(13.5, 5),
Offset(13.5, 6),
Offset(13.5, 18),
Offset(13.5, 19),
Offset(14.5, 19),
Offset(16.5, 19),
Offset(17.5, 19),
Offset(17.5, 18),
Offset(17.5, 6),
Offset(17.5, 5),
Offset(16.5, 5),
];
@override
void paint(Canvas canvas, Size size) {
final scale = size.shortestSide / 24;
canvas.save();
canvas.scale(scale, scale);
final fillPaint = Paint()
..color = color
..style = PaintingStyle.fill;
final strokePaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 0.8
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final primary = _morphPath(_playPrimary, _pausePrimary);
final secondary = _morphPath(_playSecondary, _pauseSecondary);
canvas
..drawPath(primary, fillPaint)
..drawPath(primary, strokePaint)
..drawPath(secondary, fillPaint)
..drawPath(secondary, strokePaint);
canvas.restore();
}
Path _morphPath(List<Offset> from, List<Offset> to) {
final points = [
for (var index = 0; index < from.length; index++)
Offset(
lerpDouble(from[index].dx, to[index].dx, progress)!,
lerpDouble(from[index].dy, to[index].dy, progress)!,
),
];
return Path()
..moveTo(points[0].dx, points[0].dy)
..quadraticBezierTo(
points[1].dx,
points[1].dy,
points[2].dx,
points[2].dy,
)
..lineTo(points[3].dx, points[3].dy)
..quadraticBezierTo(
points[4].dx,
points[4].dy,
points[5].dx,
points[5].dy,
)
..lineTo(points[6].dx, points[6].dy)
..quadraticBezierTo(
points[7].dx,
points[7].dy,
points[8].dx,
points[8].dy,
)
..lineTo(points[9].dx, points[9].dy)
..quadraticBezierTo(
points[10].dx,
points[10].dy,
points[11].dx,
points[11].dy,
)
..close();
}
@override
bool shouldRepaint(_VoiceNotePlayPausePainter oldDelegate) =>
oldDelegate.progress != progress || oldDelegate.color != color;
}