Add inline profile camera capture (#6680)

## Summary

- add inline profile photo capture with smooth avatar-to-viewfinder
transitions
- add close, flip, shutter, retry, and use-photo states with haptics and
front-camera mirroring
- polish iOS liquid-glass controls and avatar editor motion

### Related issue

Follow-up to #6583.

### Testing

- just mobile-check
- just mobile-test (1,743 tests)
- built, installed, and launched on Pixel 10 and iPhone Air

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz>
Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz>
Co-authored-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
klopez4212
2026-08-25 14:58:55 +01:00
committed by GitHub
parent 822c5ab231
commit 9aa332af03
33 changed files with 3609 additions and 594 deletions

View File

@@ -169,10 +169,18 @@ final class NavigationGlassButtonFactory: NSObject, FlutterPlatformViewFactory {
}
final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
private static let shutterIconRatio: CGFloat = 80.0 / 115.0
private static let shutterInsetRatio: CGFloat = 20.0 / 115.0
private let containerView: UIView
private let channel: FlutterMethodChannel
private let button = NavigationGlassButton(type: .system)
private let activityIndicator = UIActivityIndicatorView(style: .medium)
private var buttonLabel: String?
private var buttonIconName = "chevron.backward"
private var contentIcon = "back"
private var buttonImage: UIImage?
private var isBusy = false
private var controlSize: CGFloat = 40
init(
frame: CGRect,
@@ -196,12 +204,11 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
(arguments?["hitTargetWidth"] as? NSNumber)?.doubleValue ?? 48
let hitTargetHeight =
(arguments?["hitTargetHeight"] as? NSNumber)?.doubleValue ?? 48
let label = arguments?["label"] as? String
buttonLabel = label
let icon = arguments?["icon"] as? String
let symbolName = icon == "close" ? "xmark" : "chevron.backward"
let controlWidth =
(arguments?["controlWidth"] as? NSNumber)?.doubleValue ?? 40
controlSize =
(arguments?["controlSize"] as? NSNumber)?.doubleValue ?? 40
let fillWidth = arguments?["fillWidth"] as? Bool ?? false
var configuration: UIButton.Configuration
if #available(iOS 26.0, *) {
@@ -211,33 +218,15 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
configuration.baseBackgroundColor = UIColor.secondarySystemBackground
}
configuration.cornerStyle = .capsule
if let label {
configuration.title = label
configuration.titleLineBreakMode = .byClipping
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer {
incoming in
var outgoing = incoming
let preferred = UIFont.preferredFont(forTextStyle: .subheadline)
outgoing.font = UIFont.systemFont(ofSize: preferred.pointSize, weight: .semibold)
return outgoing
}
} else {
configuration.image = UIImage(
systemName: symbolName,
withConfiguration: UIImage.SymbolConfiguration(
pointSize: 17,
weight: .semibold
)
)
}
button.configuration = configuration
applyContent(from: arguments)
button.titleLabel?.numberOfLines = 1
button.titleLabel?.lineBreakMode = .byClipping
button.hitTargetInsets = UIEdgeInsets(
top: max(0, (hitTargetHeight - 40) / 2),
left: max(0, buttonCenterX - 20),
bottom: max(0, (hitTargetHeight - 40) / 2),
right: max(0, hitTargetWidth - buttonCenterX - 20)
top: max(0, (hitTargetHeight - controlSize) / 2),
left: max(0, buttonCenterX - controlWidth / 2),
bottom: max(0, (hitTargetHeight - controlSize) / 2),
right: max(0, hitTargetWidth - buttonCenterX - controlWidth / 2)
)
button.accessibilityLabel = arguments?["accessibilityLabel"] as? String ?? "Back"
button.translatesAutoresizingMaskIntoConstraints = false
@@ -248,8 +237,24 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
for: .touchUpInside
)
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
activityIndicator.hidesWhenStopped = true
activityIndicator.isUserInteractionEnabled = false
button.addSubview(activityIndicator)
NSLayoutConstraint.activate([
activityIndicator.centerXAnchor.constraint(equalTo: button.centerXAnchor),
activityIndicator.centerYAnchor.constraint(equalTo: button.centerYAnchor),
activityIndicator.widthAnchor.constraint(equalToConstant: 24),
activityIndicator.heightAnchor.constraint(equalToConstant: 24),
])
applyAppearance(from: args)
channel.setMethodCallHandler { [weak self] call, result in
if call.method == "setContent" {
self?.setContent(from: call.arguments)
result(nil)
return
}
guard call.method == "setAppearance" else {
result(FlutterMethodNotImplemented)
return
@@ -260,14 +265,19 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
containerView.addSubview(button)
NSLayoutConstraint.activate([
button.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
button.heightAnchor.constraint(equalToConstant: controlSize),
])
if fillWidth {
button.centerXAnchor.constraint(equalTo: containerView.centerXAnchor).isActive = true
button.widthAnchor.constraint(equalTo: containerView.widthAnchor).isActive = true
} else {
button.centerXAnchor.constraint(
equalTo: containerView.leadingAnchor,
constant: buttonCenterX
),
button.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
button.widthAnchor.constraint(equalToConstant: controlWidth),
button.heightAnchor.constraint(equalToConstant: 40),
])
).isActive = true
button.widthAnchor.constraint(equalToConstant: controlWidth).isActive = true
}
}
func view() -> UIView {
@@ -331,14 +341,25 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
let foregroundColor = colorValue.map(Self.color(from:))
let enabled = arguments?["enabled"] as? Bool ?? true
let busy = arguments?["busy"] as? Bool ?? false
let selected = arguments?["selected"] as? Bool ?? false
containerView.overrideUserInterfaceStyle = interfaceStyle
button.overrideUserInterfaceStyle = interfaceStyle
button.isEnabled = enabled
button.configuration?.showsActivityIndicator = busy
button.configuration?.title = busy ? nil : buttonLabel
button.isSelected = selected
isBusy = busy
if selected {
button.accessibilityTraits.insert(.selected)
} else {
button.accessibilityTraits.remove(.selected)
}
button.configuration?.showsActivityIndicator = false
if let foregroundColor {
// Glass uses the view tint for its selected treatment. Keep it aligned
// with the Buzz theme instead of falling back to the system blue tint.
button.tintColor = foregroundColor
button.configuration?.baseForegroundColor = foregroundColor
activityIndicator.color = foregroundColor
}
if #unavailable(iOS 26.0) {
button.configuration?.baseBackgroundColor = Self.fallbackBackgroundColor(
@@ -346,9 +367,104 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
interfaceStyle: interfaceStyle
)
}
updateDisplayedContent()
button.setNeedsUpdateConfiguration()
}
private func applyContent(from value: Any?) {
let arguments = value as? [String: Any]
let icon = arguments?["icon"] as? String
contentIcon = icon ?? "back"
buttonLabel = arguments?["label"] as? String
if let accessibilityLabel = arguments?["accessibilityLabel"] as? String {
button.accessibilityLabel = accessibilityLabel
}
switch icon {
case "close": buttonIconName = "xmark"
case "camera": buttonIconName = "camera"
case "photoLibrary": buttonIconName = "photo.on.rectangle.angled"
case "palette": buttonIconName = "paintpalette"
case "emoji": buttonIconName = "face.smiling"
case "person": buttonIconName = "person"
case "frame": buttonIconName = "rectangle.stack"
case "rotateCamera": buttonIconName = "arrow.triangle.2.circlepath.camera"
case "shutter": buttonIconName = "circle.fill"
default: buttonIconName = "chevron.backward"
}
if buttonLabel != nil {
buttonImage = nil
button.configuration?.contentInsets = NSDirectionalEdgeInsets(
top: 8,
leading: 8,
bottom: 8,
trailing: 8
)
button.configuration?.titleLineBreakMode = .byClipping
button.configuration?.titleTextAttributesTransformer =
UIConfigurationTextAttributesTransformer { incoming in
var outgoing = incoming
let preferred = UIFont.preferredFont(forTextStyle: .subheadline)
outgoing.font = UIFont.systemFont(
ofSize: preferred.pointSize,
weight: .semibold
)
return outgoing
}
} else {
button.configuration?.titleTextAttributesTransformer = nil
let iconInset: CGFloat = icon == "shutter"
? controlSize * Self.shutterInsetRatio
: 8
button.configuration?.contentInsets = NSDirectionalEdgeInsets(
top: iconInset,
leading: iconInset,
bottom: iconInset,
trailing: iconInset
)
let pointSize: CGFloat = icon == "shutter"
? controlSize * Self.shutterIconRatio
: 17
buttonImage = UIImage(
systemName: buttonIconName,
withConfiguration: UIImage.SymbolConfiguration(
pointSize: pointSize,
weight: .semibold
)
)
}
updateDisplayedContent()
button.setNeedsUpdateConfiguration()
}
private func setContent(from value: Any?) {
let arguments = value as? [String: Any]
let nextIcon = arguments?["icon"] as? String ?? "back"
let nextLabel = arguments?["label"] as? String
guard nextIcon != contentIcon || nextLabel != buttonLabel else {
applyContent(from: value)
return
}
let duration = UIAccessibility.isReduceMotionEnabled ? 0 : 0.12
UIView.transition(
with: button,
duration: duration,
options: [.transitionCrossDissolve, .beginFromCurrentState, .allowAnimatedContent],
animations: { [weak self] in self?.applyContent(from: value) }
)
}
private func updateDisplayedContent() {
if isBusy {
button.configuration?.title = nil
button.configuration?.image = nil
activityIndicator.startAnimating()
} else {
button.configuration?.title = buttonLabel
button.configuration?.image = buttonLabel == nil ? buttonImage : nil
activityIndicator.stopAnimating()
}
}
private static func color(from value: UInt32) -> UIColor {
let alpha = CGFloat((value >> 24) & 0xFF) / 255
let red = CGFloat((value >> 16) & 0xFF) / 255

View File

@@ -18,7 +18,9 @@ import '../../shared/emoji/emoji_avatar.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/ios_glass_navigation_button.dart';
import 'avatar_background_grid.dart';
import 'camera_disposal_barrier.dart';
import 'avatar_editor_option_button.dart';
import 'animated_avatar_orientation.dart';
import 'profile_avatar_draft.dart';
@@ -35,8 +37,6 @@ const _outputSize = 256;
const _mobileDefaultPersonScale = 1.15;
const _animatedReviewRailHeight = 88.0;
enum _AnimatedReviewSection { person, color, poster }
/// Records and prepares a short camera animation for a profile avatar.
class AnimatedAvatarCapture extends HookConsumerWidget {
/// Creates an animated-avatar capture and review surface.
@@ -45,6 +45,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
required this.height,
required this.onPrepareChanged,
this.initialFrames = const [],
this.disposalBarrier,
});
/// The vertical space available to the capture surface.
@@ -56,10 +57,18 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
/// Seeds processed frames in lifecycle-focused widget tests.
@visibleForTesting
final List<Uint8List> initialFrames;
/// Serializes ownership release with another profile capture surface.
final CameraDisposalBarrier? disposalBarrier;
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useState<CameraController?>(null);
final controllerRef = useRef<CameraController?>(null);
final controllerDisposal = useRef(
disposalBarrier ?? CameraDisposalBarrier(),
);
final candidateRef = useRef<CameraController?>(null);
final captureEpoch = useRef(0);
final cameraGeneration = useState(0);
final isInitializing = useState(true);
@@ -96,6 +105,8 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
shapeOffsetX: shapeOffset.value.dx,
shapeOffsetY: shapeOffset.value.dy,
);
final latestEncodeKey = useRef<_EncodeKey?>(null);
latestEncodeKey.value = encodeKey;
useEffect(() {
encodedCache.value = null;
final key = encodeKey;
@@ -129,15 +140,25 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
isInitializing.value = true;
Future<void> releaseController(CameraController? active) {
if (active == null) return controllerDisposal.value.settled;
return controllerDisposal.value.release(active.dispose);
}
Future<void> initialize() async {
CameraController? next;
CameraDisposalReservation? reservation;
var installed = false;
try {
await controllerDisposal.value.settled;
if (disposed) return;
final cameras = await availableCameras();
if (disposed || cameras.isEmpty) return;
final selected = cameras.firstWhere(
(camera) => camera.lensDirection == CameraLensDirection.front,
orElse: () => cameras.first,
);
final next = CameraController(
next = CameraController(
selected,
ResolutionPreset.medium,
enableAudio: false,
@@ -145,14 +166,33 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
? ImageFormatGroup.bgra8888
: ImageFormatGroup.yuv420,
);
await next.initialize();
reservation = controllerDisposal.value.reserve();
candidateRef.value = next;
await reservation.ready;
if (disposed) {
await next.dispose();
if (identical(candidateRef.value, next)) candidateRef.value = null;
await reservation.dispose(next.dispose);
return;
}
await next.initialize();
await next.lockCaptureOrientation(DeviceOrientation.portraitUp);
if (disposed) {
if (identical(candidateRef.value, next)) candidateRef.value = null;
await reservation.dispose(next.dispose);
return;
}
candidateRef.value = null;
reservation.complete();
controllerRef.value = next;
controller.value = next;
installed = true;
} catch (_) {
if (!installed &&
next != null &&
identical(candidateRef.value, next)) {
candidateRef.value = null;
await reservation?.dispose(next.dispose);
}
if (!disposed) error.value = 'Could not access the camera.';
} finally {
if (!disposed) isInitializing.value = false;
@@ -165,12 +205,12 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
captureEpoch.value++;
final active = controllerRef.value;
controllerRef.value = null;
unawaited(active?.dispose() ?? Future<void>.value());
unawaited(releaseController(active));
};
}, [lifecycle, frames.value.isEmpty, cameraGeneration.value]);
Future<ProfileAvatarDraft?> prepare() async {
final key = encodeKey;
final key = latestEncodeKey.value;
if (key == null) return null;
isProcessing.value = true;
error.value = null;
@@ -242,7 +282,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
if (context.mounted && identical(controller.value, active)) {
controller.value = null;
}
await active.dispose();
await controllerDisposal.value.release(active.dispose);
}
final timer = Timer.periodic(const Duration(milliseconds: 40), (_) {
@@ -290,12 +330,14 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
if (captureEpoch.value != currentCapture || !context.mounted) return;
await releaseCamera();
if (captured.length < 2) {
throw StateError('Not enough frames were captured.');
}
isRecording.value = false;
// Enter the processing state before releasing the controller. Clearing
// the camera first briefly exposed the unavailable-camera placeholder.
isPreparingFrames.value = true;
await releaseCamera();
// Cut out only the frames each device captured, then resample the
// three-second window so Android and iOS use the same playback cadence.
final cutouts = await _removeBackgrounds(captured);
@@ -342,7 +384,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
final previewTop = activeSection.value == _AnimatedReviewSection.color
? -avatarBackgroundPreviewShift
: 0.0;
final controlsTop = previewTop + 220;
final controlsTop = previewTop + _animatedAvatarPreviewSize;
return SizedBox(
height: height,
child: Stack(
@@ -357,7 +399,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
left: 0,
right: 0,
top: previewTop,
height: 220,
height: _animatedAvatarPreviewSize,
child: Center(
child: _RepositionablePreviewSemantics(
offset: offset.value,
@@ -385,7 +427,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
.toDouble();
},
child: SizedBox.square(
dimension: 220,
dimension: _animatedAvatarPreviewSize,
child: Stack(
fit: StackFit.expand,
children: [
@@ -396,13 +438,17 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
Center(
child: Transform.translate(
offset:
const Offset(0, 20.625) +
shapeOffset.value * 51.5625,
const Offset(
0,
_animatedAvatarShapeYOffset,
) +
shapeOffset.value *
_animatedAvatarShapeTranslation,
child: Transform.scale(
scale: shapeScale.value,
child: Container(
width: 172,
height: 172,
width: _animatedAvatarShapeSize,
height: _animatedAvatarShapeSize,
decoration: BoxDecoration(
color: Color(backdropColor.value),
shape: BoxShape.circle,
@@ -413,7 +459,9 @@ class AnimatedAvatarCapture extends HookConsumerWidget {
),
_AnimatedPersonPreview(
bytes: selectedFrame,
offset: offset.value * 48,
offset:
offset.value *
_animatedAvatarPersonTranslation,
scale: scale.value,
outline: personOutline.value,
outlineColor: _personOutlineColor(
@@ -634,13 +682,6 @@ List<Uint8List> _resampleCapturedFrames(
}, growable: false);
}
Color _personOutlineColor(int backdropColor) {
final color = Color(backdropColor);
return color.computeLuminance() > 0.74
? const Color(0xFF111111)
: Colors.white;
}
@immutable
class _FramePlane {
const _FramePlane(this.bytes, this.bytesPerRow, this.bytesPerPixel);
@@ -880,12 +921,14 @@ _EncodedAvatar _encodeAvatar(_EncodeRequest request) {
height: _outputSize,
numChannels: 4,
);
const previewSize = 220.0;
const previewTranslation = 48.0;
const previewSize = _animatedAvatarPreviewSize;
const previewTranslation = _animatedAvatarPersonTranslation;
final translationScale = _outputSize / previewSize;
image.compositeImage(
person,
scaledPerson,
dstW: scaledSize,
dstH: scaledSize,
dstX:
((_outputSize - scaledSize) / 2 +
request.offsetX * previewTranslation * translationScale)
@@ -903,9 +946,9 @@ _EncodedAvatar _encodeAvatar(_EncodeRequest request) {
final color = request.backdropColor;
image.fillCircle(
frame,
x: (_outputSize / 2 + request.shapeOffsetX * 60).round(),
y: (_outputSize / 2 + 24 + request.shapeOffsetY * 60).round(),
radius: (100 * request.shapeScale).round(),
x: _animatedAvatarShapeX(request.shapeOffsetX),
y: _animatedAvatarShapeY(request.shapeOffsetY),
radius: _animatedAvatarShapeRadius(request.shapeScale),
color: image.ColorRgba8(
(color >> 16) & 0xff,
(color >> 8) & 0xff,
@@ -923,16 +966,7 @@ _EncodedAvatar _encodeAvatar(_EncodeRequest request) {
..b = (outlineColor.b * 255).round()
..a = (pixel.a * 0.92).round();
}
for (final (x, y) in const [
(-2, 0),
(2, 0),
(0, -2),
(0, 2),
(-1, -1),
(1, -1),
(-1, 1),
(1, 1),
]) {
for (final (x, y) in _animatedAvatarOutlineOffsets) {
image.compositeImage(frame, outline, dstX: x, dstY: y);
}
}
@@ -956,26 +990,6 @@ _EncodedAvatar _encodeAvatar(_EncodeRequest request) {
return _EncodedAvatar(animation, poster);
}
/// Encodes one poster frame for validating animated-avatar framing parity.
@visibleForTesting
Uint8List encodeAnimatedAvatarPoster({
required Uint8List frame,
required double scale,
}) => _encodeAvatar(
_EncodeRequest(
frames: [frame],
posterIndex: 0,
scale: scale,
offsetX: 0,
offsetY: 0,
backdropColor: 0xff0000ff,
personOutline: false,
shapeScale: 1,
shapeOffsetX: 0,
shapeOffsetY: 0,
),
).poster;
extension<T> on Iterable<T> {
Iterable<T> skipLast(int count) {
final values = toList(growable: false);

View File

@@ -1,5 +1,39 @@
part of '../animated_avatar_capture.dart';
const _animatedAvatarPreviewSize = 220.0;
const _animatedAvatarPersonTranslation = 48.0;
const _animatedAvatarShapeSize = 172.0;
const _animatedAvatarShapeYOffset = 20.625;
const _animatedAvatarShapeTranslation = 51.5625;
const _animatedAvatarOutputScale = _outputSize / _animatedAvatarPreviewSize;
const _animatedAvatarOutlineOffsets = [
(-3, 0),
(3, 0),
(0, -3),
(0, 3),
(-2, -2),
(2, -2),
(-2, 2),
(2, 2),
];
int _animatedAvatarShapeX(double offset) =>
(_outputSize / 2 +
offset *
_animatedAvatarShapeTranslation *
_animatedAvatarOutputScale)
.round();
int _animatedAvatarShapeY(double offset) =>
(_outputSize / 2 +
(_animatedAvatarShapeYOffset +
offset * _animatedAvatarShapeTranslation) *
_animatedAvatarOutputScale)
.round();
int _animatedAvatarShapeRadius(double scale) =>
(_animatedAvatarShapeSize / 2 * _animatedAvatarOutputScale * scale).round();
class _AnimatedRecordButton extends StatelessWidget {
const _AnimatedRecordButton({required this.busy, required this.onPressed});
@@ -19,39 +53,57 @@ class _AnimatedRecordButton extends StatelessWidget {
curve: Curves.easeOutCubic,
width: busy ? 64 : constraints.maxWidth,
height: 64,
child: Material(
color: context.colors.onSurface,
borderRadius: BorderRadius.circular(Radii.full),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: const ValueKey('animated-avatar-record'),
onTap: busy ? null : onPressed,
child: Center(
child: AnimatedSwitcher(
duration: reduceMotion
? Duration.zero
: const Duration(milliseconds: 150),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeOutCubic,
child: busy
? BuzzLoadingIndicator(
key: const ValueKey('animated-avatar-capturing'),
size: 24,
color: context.colors.surface,
semanticLabel: 'Capturing animated avatar',
)
: Text(
'Record',
key: const ValueKey('animated-avatar-record-label'),
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.surface,
fontWeight: FontWeight.w600,
),
),
child: defaultTargetPlatform == TargetPlatform.iOS
? IosGlassNavigationButton(
key: const ValueKey('animated-avatar-record'),
icon: IosGlassNavigationIcon.shutter,
label: busy ? null : 'Record',
semanticLabel: 'Record animated avatar',
onPressed: busy ? null : onPressed,
width: busy ? 64 : constraints.maxWidth,
height: 64,
controlSize: 64,
fillWidth: true,
foregroundColor: context.colors.onSurface,
isBusy: busy,
)
: Material(
color: context.colors.onSurface,
borderRadius: BorderRadius.circular(Radii.full),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: const ValueKey('animated-avatar-record'),
onTap: busy ? null : onPressed,
child: Center(
child: AnimatedSwitcher(
duration: reduceMotion
? Duration.zero
: const Duration(milliseconds: 150),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeOutCubic,
child: busy
? BuzzLoadingIndicator(
key: const ValueKey(
'animated-avatar-capturing',
),
size: 24,
color: context.colors.surface,
semanticLabel: 'Capturing animated avatar',
)
: Text(
'Record',
key: const ValueKey(
'animated-avatar-record-label',
),
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.surface,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
),
),
),
),
),
);
@@ -65,13 +117,7 @@ class _AspectCorrectCameraPreview extends StatelessWidget {
@override
Widget build(BuildContext context) {
final orientation = controller.value.deviceOrientation;
final isLandscape =
orientation == DeviceOrientation.landscapeLeft ||
orientation == DeviceOrientation.landscapeRight;
final aspectRatio = isLandscape
? controller.value.aspectRatio
: 1 / controller.value.aspectRatio;
final aspectRatio = 1 / controller.value.aspectRatio;
return FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,

View File

@@ -20,3 +20,10 @@ class _ErrorText extends StatelessWidget {
),
);
}
Color _personOutlineColor(int backdropColor) {
final color = Color(backdropColor);
return color.computeLuminance() > 0.74
? const Color(0xFF111111)
: Colors.white;
}

View File

@@ -88,3 +88,23 @@ Uint8List _applySegmentationMask(_MaskRequest request) {
}
return image.encodePng(result, level: 4);
}
/// Encodes one poster frame for validating animated-avatar framing parity.
@visibleForTesting
Uint8List encodeAnimatedAvatarPoster({
required Uint8List frame,
required double scale,
}) => _encodeAvatar(
_EncodeRequest(
frames: [frame],
posterIndex: 0,
scale: scale,
offsetX: 0,
offsetY: 0,
backdropColor: 0xff0000ff,
personOutline: false,
shapeScale: 1,
shapeOffsetX: 0,
shapeOffsetY: 0,
),
).poster;

View File

@@ -1,5 +1,7 @@
part of '../animated_avatar_capture.dart';
enum _AnimatedReviewSection { person, color, poster }
class _RepositionablePreviewSemantics extends StatelessWidget {
const _RepositionablePreviewSemantics({
required this.offset,
@@ -53,6 +55,7 @@ class _AnimatedReviewNav extends StatelessWidget {
Expanded(
child: AvatarEditorOptionButton(
icon: LucideIcons.userRound,
iosIcon: IosGlassNavigationIcon.person,
label: 'You',
selected: selected == _AnimatedReviewSection.person,
onTap: () => onSelected(_AnimatedReviewSection.person),
@@ -62,6 +65,7 @@ class _AnimatedReviewNav extends StatelessWidget {
Expanded(
child: AvatarEditorOptionButton(
icon: LucideIcons.palette,
iosIcon: IosGlassNavigationIcon.palette,
label: 'Background',
selected: selected == _AnimatedReviewSection.color,
onTap: () => onSelected(_AnimatedReviewSection.color),
@@ -71,6 +75,7 @@ class _AnimatedReviewNav extends StatelessWidget {
Expanded(
child: AvatarEditorOptionButton(
icon: LucideIcons.galleryThumbnails,
iosIcon: IosGlassNavigationIcon.frame,
label: 'Frame',
selected: selected == _AnimatedReviewSection.poster,
onTap: () => onSelected(_AnimatedReviewSection.poster),
@@ -82,6 +87,7 @@ class _AnimatedReviewNav extends StatelessWidget {
Expanded(
child: AvatarEditorOptionButton(
icon: LucideIcons.camera,
iosIcon: IosGlassNavigationIcon.camera,
label: 'Retake',
selected: false,
onTap: onRetake,

View File

@@ -1,9 +1,14 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/ios_glass_navigation_button.dart';
/// Space between avatar-editor controls and their labels.
const avatarEditorOptionLabelGap = Grid.half;
/// A labelled circular option used by the profile avatar editor rails.
class AvatarEditorOptionButton extends StatelessWidget {
@@ -11,6 +16,7 @@ class AvatarEditorOptionButton extends StatelessWidget {
const AvatarEditorOptionButton({
super.key,
required this.icon,
this.iosIcon,
required this.label,
required this.selected,
required this.onTap,
@@ -20,6 +26,9 @@ class AvatarEditorOptionButton extends StatelessWidget {
/// The symbol displayed inside the circular control.
final IconData icon;
/// Native symbol used by the iOS liquid-glass control.
final IosGlassNavigationIcon? iosIcon;
/// The text displayed beneath the control.
final String label;
@@ -40,6 +49,59 @@ class AvatarEditorOptionButton extends StatelessWidget {
unawaited(HapticFeedback.selectionClick());
onTap!();
};
Widget labelWidget() {
final width = labelMaxWidth;
if (width != null) {
return SizedBox(
height: 20,
child: OverflowBox(
maxWidth: width,
maxHeight: 20,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelSmall?.copyWith(
color: selected
? context.colors.onSurface
: context.colors.onSurfaceVariant,
),
),
),
);
}
return Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelSmall?.copyWith(
color: selected
? context.colors.onSurface
: context.colors.onSurfaceVariant,
),
);
}
if (defaultTargetPlatform == TargetPlatform.iOS && iosIcon != null) {
return Column(
children: [
IosGlassNavigationButton(
icon: iosIcon!,
semanticLabel: label,
onPressed: handleTap,
width: 64,
height: 64,
controlSize: 64,
foregroundColor: selected
? context.colors.primary
: context.colors.onSurface,
isSelected: selected,
),
const SizedBox(height: avatarEditorOptionLabelGap),
ExcludeSemantics(child: labelWidget()),
],
);
}
return Semantics(
label: label,
button: true,
@@ -75,36 +137,8 @@ class AvatarEditorOptionButton extends StatelessWidget {
: context.colors.onSurface,
),
),
const SizedBox(height: Grid.quarter),
if (labelMaxWidth case final width?)
SizedBox(
height: 20,
child: OverflowBox(
maxWidth: width,
maxHeight: 20,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelSmall?.copyWith(
color: selected
? context.colors.onSurface
: context.colors.onSurfaceVariant,
),
),
),
)
else
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelSmall?.copyWith(
color: selected
? context.colors.onSurface
: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: avatarEditorOptionLabelGap),
labelWidget(),
],
),
),

View File

@@ -0,0 +1,71 @@
import 'dart:async';
/// Serializes camera teardown so replacement sessions never overlap.
///
/// A native disposal failure is intentionally contained: callers can proceed
/// with the next camera session after the failed release has settled.
class CameraDisposalBarrier {
Future<void> _pending = Future<void>.value();
/// Reserves camera ownership while a controller is initializing.
CameraDisposalReservation reserve() {
final reservation = CameraDisposalReservation._(_pending);
_pending = reservation._settled;
return reservation;
}
/// Completes after earlier and [dispose] callbacks have settled.
///
/// Exceptions from [dispose] are handled so they cannot prevent a later
/// replacement session from acquiring the camera.
Future<void> release(Future<void> Function() dispose) {
final previous = _pending;
final release = () async {
await previous;
try {
await dispose();
} catch (_) {
// A failed native release must not permanently block camera recovery.
}
}();
_pending = release;
return release;
}
/// Completes once all scheduled teardown work has settled.
Future<void> get settled => _pending;
}
/// Owns one in-flight camera initialization in a [CameraDisposalBarrier].
class CameraDisposalReservation {
CameraDisposalReservation._(this._previous) {
_settled = () async {
await _previous;
await _completion.future;
}();
}
final Future<void> _previous;
final _completion = Completer<void>();
late final Future<void> _settled;
/// Waits for older camera teardown before this candidate initializes.
Future<void> get ready => _previous;
/// Releases this reservation after a successful initialization.
void complete() {
if (!_completion.isCompleted) _completion.complete();
}
/// Disposes a failed or cancelled candidate before allowing replacement.
Future<void> dispose(Future<void> Function() release) async {
await _previous;
try {
await release();
} catch (_) {
// A failed native release must not permanently block camera recovery.
} finally {
complete();
}
}
}

View File

@@ -0,0 +1,662 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image/image.dart' as image;
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/ios_glass_navigation_button.dart';
import 'camera_disposal_barrier.dart';
part 'image_avatar_capture/camera_preview.dart';
part 'image_avatar_capture/morphing_camera_action.dart';
part 'image_avatar_capture/shutter_button.dart';
const _avatarPreviewSize = 220.0;
/// Diameter of the expanded circular viewfinder while taking a profile photo.
const imageAvatarCameraPreviewSize = _avatarPreviewSize * 1.25;
const _cameraControlSize = 64.0;
const _cameraControlRailHeight = 115.0;
const _shutterSize = _cameraControlSize * 1.5625;
const _shutterCoreSize = _shutterSize * (99 / 115);
const _reviewControlWidth = 112.0;
const _expandedControlOffset = 119.5;
const _reviewControlGap = Grid.twelve;
const _captureMotionDuration = Duration(milliseconds: 180);
const _shutterExitDuration = Duration(milliseconds: 150);
const _cameraFlipHalfDuration = Duration(milliseconds: 100);
/// Builds the inline still-photo camera used by the profile avatar editor.
typedef ImageAvatarCaptureBuilder =
Widget Function({
required double height,
required ValueChanged<Uint8List> onAccepted,
required VoidCallback onClosed,
});
/// Captures a still profile photo inside the avatar's circular viewfinder.
class ImageAvatarCapture extends HookConsumerWidget {
/// Creates the inline image capture surface.
const ImageAvatarCapture({
super.key,
required this.height,
required this.onAccepted,
required this.onClosed,
this.initialPreview,
this.initialCapturedBytes,
this.loadCameras = availableCameras,
this.disposalBarrier,
});
/// The vertical space available to the camera and its controls.
final double height;
/// Accepts the captured, square image as an unsaved avatar draft.
final ValueChanged<Uint8List> onAccepted;
/// Leaves camera mode without changing the current avatar draft.
final VoidCallback onClosed;
/// The existing avatar shown while the same circular cutout becomes a camera.
final Widget? initialPreview;
/// Seeds the captured-photo review state in focused widget tests.
@visibleForTesting
final Uint8List? initialCapturedBytes;
/// Loads device cameras. Overridden by focused widget tests.
@visibleForTesting
final Future<List<CameraDescription>> Function() loadCameras;
/// Serializes ownership release with another profile capture surface.
final CameraDisposalBarrier? disposalBarrier;
@override
Widget build(BuildContext context, WidgetRef ref) {
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final lifecycle = ref.watch(appLifecycleProvider);
final controller = useState<CameraController?>(null);
final controllerRef = useRef<CameraController?>(null);
final controllerDisposal = useRef(
disposalBarrier ?? CameraDisposalBarrier(),
);
final candidateRef = useRef<CameraController?>(null);
final cameras = useState<List<CameraDescription>>(const []);
final selectedLens = useState(CameraLensDirection.front);
final cameraGeneration = useState(0);
final flipAnimation = useAnimationController(
duration: _cameraFlipHalfDuration * 2,
);
final flipDirection = useState(1.0);
final isInitializing = useState(initialCapturedBytes == null);
final isFlipping = useState(false);
final isCapturing = useState(false);
final isProcessingCapture = useState(false);
final capturedBytes = useState<Uint8List?>(initialCapturedBytes);
final controlsExpanded = useState(false);
final isClosing = useState(false);
final error = useState<String?>(null);
Future<void> releaseController(CameraController? active) {
if (active == null) return controllerDisposal.value.settled;
return controllerDisposal.value.release(active.dispose);
}
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
controlsExpanded.value = true;
});
return null;
}, const []);
useEffect(
() => () {
final active = controllerRef.value;
controllerRef.value = null;
unawaited(releaseController(active));
},
const [],
);
useEffect(() {
var disposed = false;
final generation = cameraGeneration.value;
if (lifecycle != AppLifecycleState.resumed ||
capturedBytes.value != null) {
isInitializing.value = false;
final active = controllerRef.value;
controllerRef.value = null;
controller.value = null;
unawaited(releaseController(active));
return null;
}
isInitializing.value = true;
error.value = null;
Future<void> initialize() async {
CameraController? next;
CameraDisposalReservation? reservation;
var installed = false;
try {
await controllerDisposal.value.settled;
if (disposed || generation != cameraGeneration.value) return;
final available = await loadCameras();
if (disposed || generation != cameraGeneration.value) return;
cameras.value = available;
if (available.isEmpty) {
throw CameraException(
'no-cameras',
'No cameras are available on this device.',
);
}
final description = available.firstWhere(
(candidate) => candidate.lensDirection == selectedLens.value,
orElse: () => available.first,
);
selectedLens.value = description.lensDirection;
next = CameraController(
description,
ResolutionPreset.high,
enableAudio: false,
);
reservation = controllerDisposal.value.reserve();
candidateRef.value = next;
await reservation.ready;
if (disposed) {
if (identical(candidateRef.value, next)) candidateRef.value = null;
await reservation.dispose(next.dispose);
return;
}
await next.initialize();
await next.lockCaptureOrientation(DeviceOrientation.portraitUp);
if (disposed || generation != cameraGeneration.value) {
if (identical(candidateRef.value, next)) candidateRef.value = null;
await reservation.dispose(next.dispose);
return;
}
candidateRef.value = null;
reservation.complete();
final previous = controllerRef.value;
controllerRef.value = next;
controller.value = next;
installed = true;
if (previous != null && previous != next) {
unawaited(releaseController(previous));
}
} catch (_) {
if (!installed && next != null) {
final activeCandidate = identical(candidateRef.value, next);
if (activeCandidate) candidateRef.value = null;
if (activeCandidate) {
await reservation?.dispose(next.dispose);
}
}
if (!disposed && generation == cameraGeneration.value) {
final active = controllerRef.value;
if (active != null) {
selectedLens.value = active.description.lensDirection;
}
error.value = 'Could not access the camera.';
}
} finally {
if (!disposed && generation == cameraGeneration.value) {
isInitializing.value = false;
}
}
}
unawaited(initialize());
return () {
disposed = true;
};
}, [lifecycle, capturedBytes.value == null, cameraGeneration.value]);
Future<void> capture() async {
final active = controller.value;
if (active == null || isCapturing.value || active.value.isTakingPicture) {
return;
}
isCapturing.value = true;
error.value = null;
XFile? photo;
try {
unawaited(HapticFeedback.mediumImpact());
photo = await active.takePicture();
try {
await active.pausePreview();
} on CameraException {
// Some camera backends pause automatically after a still capture.
}
if (context.mounted) isProcessingCapture.value = true;
final prepared = await ref
.read(mediaUploadServiceProvider)
.prepareImageBytes(photo);
final cropped = await compute(_centerCropCameraImage, (
bytes: prepared,
mirror: active.description.lensDirection == CameraLensDirection.front,
));
if (context.mounted) capturedBytes.value = cropped;
} catch (_) {
if (context.mounted) {
error.value = "We couldn't take that photo. Try again.";
try {
await active.resumePreview();
} on CameraException {
if (identical(controllerRef.value, active)) {
controllerRef.value = null;
controller.value = null;
await releaseController(active);
if (context.mounted) cameraGeneration.value++;
}
}
}
} finally {
final path = photo?.path;
if (path != null && path.isNotEmpty) {
try {
await File(path).delete();
} on FileSystemException {
// The camera plugin can remove its temporary file independently.
}
}
if (context.mounted) {
isCapturing.value = false;
isProcessingCapture.value = false;
}
}
}
Future<void> flipCamera() async {
if (isInitializing.value ||
isFlipping.value ||
isCapturing.value ||
cameras.value.length < 2) {
return;
}
final active = controller.value;
if (active == null) return;
final nextLens = selectedLens.value == CameraLensDirection.front
? CameraLensDirection.back
: CameraLensDirection.front;
final matches = cameras.value.where(
(camera) => camera.lensDirection == nextLens,
);
if (matches.isEmpty) return;
unawaited(HapticFeedback.selectionClick());
isFlipping.value = true;
flipDirection.value = nextLens == CameraLensDirection.back ? 1 : -1;
error.value = null;
final flipMotion = reduceMotion
? null
: flipAnimation.animateTo(
1,
duration: _cameraFlipHalfDuration * 2,
curve: Curves.easeInOutCubic,
);
try {
if (!reduceMotion) await Future<void>.delayed(_cameraFlipHalfDuration);
if (!context.mounted) return;
await active.setDescription(matches.first);
if (context.mounted) selectedLens.value = nextLens;
await active.lockCaptureOrientation(DeviceOrientation.portraitUp);
} on CameraException {
if (context.mounted) error.value = 'Could not switch cameras.';
} finally {
if (context.mounted && flipMotion != null) {
try {
await flipMotion.orCancel;
} on TickerCanceled {
// The view was disposed while the camera was switching.
}
}
if (context.mounted) {
flipAnimation.value = 0;
isFlipping.value = false;
}
}
}
void retake() {
unawaited(HapticFeedback.selectionClick());
capturedBytes.value = null;
error.value = null;
cameraGeneration.value++;
}
Future<void> leaveCamera(Uint8List? acceptedBytes) async {
if (isClosing.value) return;
unawaited(
acceptedBytes == null
? HapticFeedback.selectionClick()
: HapticFeedback.mediumImpact(),
);
isClosing.value = true;
controlsExpanded.value = false;
if (!reduceMotion) await Future<void>.delayed(_captureMotionDuration);
if (!context.mounted) return;
if (acceptedBytes == null) {
onClosed();
} else {
onAccepted(acceptedBytes);
}
}
final captured = capturedBytes.value;
final previewSize = controlsExpanded.value
? imageAvatarCameraPreviewSize
: _avatarPreviewSize;
final captureEnabled =
controller.value != null &&
!isInitializing.value &&
!isFlipping.value &&
!isCapturing.value &&
!isClosing.value;
final hasOppositeLens = cameras.value.any(
(camera) =>
camera.lensDirection ==
(selectedLens.value == CameraLensDirection.front
? CameraLensDirection.back
: CameraLensDirection.front),
);
final flipEnabled =
hasOppositeLens &&
!isInitializing.value &&
!isFlipping.value &&
!isCapturing.value &&
!isClosing.value;
return SizedBox(
key: const ValueKey('image-avatar-camera'),
height: height,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
top: 0,
height: imageAvatarCameraPreviewSize,
child: Center(
child: AnimatedBuilder(
animation: flipAnimation,
builder: (context, child) {
final progress = flipAnimation.value;
final angle = progress <= 0.5
? progress * pi
: (progress - 1) * pi;
return Transform(
key: const ValueKey('image-camera-preview-flip'),
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.0015)
..rotateY(angle * flipDirection.value),
child: child,
);
},
child: AnimatedContainer(
key: const ValueKey('image-camera-preview-size'),
duration: reduceMotion
? Duration.zero
: _captureMotionDuration,
curve: Curves.easeOutCubic,
width: previewSize,
height: previewSize,
child: ClipOval(
child: ColoredBox(
color: Colors.black,
child: captured != null
? Image.memory(captured, fit: BoxFit.cover)
: controller.value != null
? _CameraPreview(controller: controller.value!)
: initialPreview != null
? FittedBox(
key: const ValueKey(
'image-camera-initial-preview-scale',
),
fit: BoxFit.cover,
child: SizedBox.square(
dimension: _avatarPreviewSize,
child: initialPreview,
),
)
: Center(
child: isInitializing.value
? const BuzzLoadingIndicator(
semanticLabel: 'Starting camera',
)
: const Icon(LucideIcons.cameraOff, size: 32),
),
),
),
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: _cameraControlRailHeight,
child: TweenAnimationBuilder<double>(
tween: Tween(end: controlsExpanded.value ? 1 : 0),
duration: reduceMotion ? Duration.zero : _captureMotionDuration,
curve: Curves.easeOutCubic,
builder: (context, progress, _) => LayoutBuilder(
builder: (context, constraints) {
final collapsedControlOffset =
(constraints.maxWidth + Grid.half * 3) / 8;
final sideOffset =
collapsedControlOffset +
(_expandedControlOffset - collapsedControlOffset) *
progress;
return TweenAnimationBuilder<double>(
tween: Tween(
end: captured == null || isClosing.value ? 0 : 1,
),
duration: reduceMotion
? Duration.zero
: _captureMotionDuration,
curve: Curves.easeInOutCubic,
builder: (context, reviewProgress, _) {
final sideWidth =
_cameraControlSize +
(_reviewControlWidth - _cameraControlSize) *
reviewProgress;
final reviewSideOffset =
_reviewControlWidth / 2 + _reviewControlGap / 2;
final effectiveSideOffset =
sideOffset +
(reviewSideOffset - sideOffset) * reviewProgress;
return Stack(
alignment: Alignment.center,
children: [
Positioned(
left:
constraints.maxWidth / 2 -
effectiveSideOffset -
sideWidth / 2,
width: sideWidth,
height: _cameraControlRailHeight,
child: _MorphingCameraAction(
controlKey: const ValueKey(
'image-camera-left-action',
),
width: sideWidth,
icon: isClosing.value
? LucideIcons.camera
: LucideIcons.x,
iosIcon: isClosing.value
? IosGlassNavigationIcon.camera
: IosGlassNavigationIcon.close,
label: captured != null && !isClosing.value
? 'Retry'
: null,
transitionLabel: isClosing.value
? 'Camera'
: null,
transitionLabelMaxWidth: 96,
transitionProgress: isClosing.value
? 1 - progress
: 0,
showEnabledAppearance: isClosing.value,
semanticLabel: isClosing.value
? 'Camera'
: captured == null
? 'Close camera'
: 'Retry',
onTap:
isFlipping.value ||
isCapturing.value ||
isClosing.value
? null
: captured == null
? () => unawaited(leaveCamera(null))
: retake,
),
),
TweenAnimationBuilder<double>(
tween: Tween(end: controlsExpanded.value ? 1 : 0),
duration: reduceMotion
? Duration.zero
: isClosing.value
? _shutterExitDuration
: _captureMotionDuration,
curve: Curves.easeOutCubic,
builder: (context, shutterProgress, _) =>
Transform.scale(
scale:
(0.73 + 0.27 * shutterProgress) *
(1 - 0.28 * reviewProgress),
child: Opacity(
key: const ValueKey(
'image-camera-shutter-exit-opacity',
),
opacity:
shutterProgress * (1 - reviewProgress),
child: IgnorePointer(
ignoring: captured != null,
child: _ShutterButton(
busy: isProcessingCapture.value,
onTap: captureEnabled
? () => unawaited(capture())
: null,
),
),
),
),
),
Positioned(
left:
constraints.maxWidth / 2 +
effectiveSideOffset -
sideWidth / 2,
width: sideWidth,
height: _cameraControlRailHeight,
child: _MorphingCameraAction(
controlKey: const ValueKey(
'image-camera-right-action',
),
width: sideWidth,
icon: isClosing.value
? LucideIcons.images
: LucideIcons.switchCamera,
iosIcon: isClosing.value
? IosGlassNavigationIcon.photoLibrary
: IosGlassNavigationIcon.rotateCamera,
label: captured != null && !isClosing.value
? 'Use Photo'
: null,
transitionLabel: isClosing.value
? 'Photo Library'
: null,
transitionLabelMaxWidth: 104,
transitionProgress: isClosing.value
? 1 - progress
: 0,
showEnabledAppearance: isClosing.value,
semanticLabel: isClosing.value
? 'Photo Library'
: captured == null
? 'Flip camera'
: 'Use Photo',
onTap: isClosing.value
? null
: captured != null
? () => unawaited(leaveCamera(captured))
: flipEnabled
? () => unawaited(flipCamera())
: null,
),
),
],
);
},
);
},
),
),
),
if (error.value != null)
Positioned(
left: 0,
right: 0,
bottom: _cameraControlRailHeight + Grid.xs,
child: Semantics(
liveRegion: true,
child: Text(
error.value!,
textAlign: TextAlign.center,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
),
),
],
),
);
}
}
Uint8List _centerCropCameraImage(({Uint8List bytes, bool mirror}) request) {
var decoded = image.decodeImage(request.bytes);
if (decoded == null) throw const FormatException('Invalid camera image');
if (request.mirror) decoded = image.flipHorizontal(decoded);
final side = min(decoded.width, decoded.height);
final cropped = image.copyCrop(
decoded,
x: (decoded.width - side) ~/ 2,
y: (decoded.height - side) ~/ 2,
width: side,
height: side,
);
final resized = side == 512
? cropped
: image.copyResize(
cropped,
width: 512,
height: 512,
interpolation: image.Interpolation.cubic,
);
return Uint8List.fromList(image.encodeJpg(resized, quality: 92));
}
/// Prepares a camera image with the same mirroring and crop used by capture.
@visibleForTesting
Uint8List prepareCameraImageForTesting(
Uint8List bytes, {
required bool mirror,
}) => _centerCropCameraImage((bytes: bytes, mirror: mirror));

View File

@@ -0,0 +1,21 @@
part of '../image_avatar_capture.dart';
class _CameraPreview extends StatelessWidget {
const _CameraPreview({required this.controller});
final CameraController controller;
@override
Widget build(BuildContext context) {
final aspectRatio = 1 / controller.value.aspectRatio;
return FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: imageAvatarCameraPreviewSize * aspectRatio,
height: imageAvatarCameraPreviewSize,
child: CameraPreview(controller),
),
);
}
}

View File

@@ -0,0 +1,149 @@
part of '../image_avatar_capture.dart';
class _MorphingCameraAction extends StatelessWidget {
const _MorphingCameraAction({
required this.controlKey,
required this.width,
required this.icon,
required this.iosIcon,
required this.label,
required this.transitionLabel,
required this.transitionLabelMaxWidth,
required this.transitionProgress,
required this.showEnabledAppearance,
required this.semanticLabel,
required this.onTap,
});
final Key controlKey;
final double width;
final IconData icon;
final IosGlassNavigationIcon iosIcon;
final String? label;
final String? transitionLabel;
final double transitionLabelMaxWidth;
final double transitionProgress;
final bool showEnabledAppearance;
final String semanticLabel;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
Widget control;
if (defaultTargetPlatform == TargetPlatform.iOS) {
control = IgnorePointer(
ignoring: onTap == null,
child: IosGlassNavigationButton(
icon: iosIcon,
label: label,
semanticLabel: semanticLabel,
onPressed: onTap ?? (showEnabledAppearance ? () {} : null),
width: width,
height: _cameraControlSize,
controlSize: _cameraControlSize,
fillWidth: true,
foregroundColor: context.colors.onSurface,
),
);
} else {
final dimmed = onTap == null && !showEnabledAppearance;
control = Semantics(
label: semanticLabel,
button: true,
enabled: onTap != null,
child: ExcludeSemantics(
child: Material(
color: context.colors.surfaceContainerHighest,
shape: const StadiumBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: SizedBox(
width: width,
height: _cameraControlSize,
child: Center(
child: AnimatedSwitcher(
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 120),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeOutCubic,
child: label == null
? Icon(
icon,
key: ValueKey('camera-action-icon-${iosIcon.name}'),
size: 26,
color: dimmed
? context.colors.onSurface.withValues(
alpha: 0.38,
)
: context.colors.onSurface,
)
: Text(
label!,
key: ValueKey(label),
maxLines: 1,
style: context.textTheme.labelMedium?.copyWith(
color: dimmed
? context.colors.onSurface.withValues(
alpha: 0.38,
)
: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
),
),
);
}
final labelProgress = transitionProgress.clamp(0.0, 1.0);
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
key: controlKey,
left: 0,
right: 0,
top: (_cameraControlRailHeight - _cameraControlSize) / 2,
height: _cameraControlSize,
child: Transform.translate(
offset: Offset(0, 1.5 * labelProgress),
child: control,
),
),
if (transitionLabel != null)
Positioned(
left: 0,
right: 0,
top: _cameraControlRailHeight - 20,
height: 20,
child: Opacity(
key: ValueKey('camera-transition-label-${transitionLabel!}'),
opacity: labelProgress,
child: Transform.translate(
offset: Offset(0, 2 * (1 - labelProgress)),
child: OverflowBox(
minWidth: 0,
maxWidth: transitionLabelMaxWidth,
maxHeight: 20,
child: Text(
transitionLabel!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,75 @@
part of '../image_avatar_capture.dart';
class _ShutterButton extends StatelessWidget {
const _ShutterButton({required this.busy, required this.onTap});
final bool busy;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
final content = SizedBox(
key: const ValueKey('image-camera-shutter-morph'),
width: _shutterSize,
height: _shutterSize,
child: isIos
? IosGlassNavigationButton(
icon: IosGlassNavigationIcon.shutter,
semanticLabel: 'Take photo',
onPressed: onTap,
width: _shutterSize,
height: _shutterSize,
controlSize: _shutterSize,
foregroundColor: context.colors.onSurface,
isBusy: busy,
)
: Material(
color: context.colors.surfaceContainerHighest,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: const ValueKey('image-camera-shutter'),
onTap: onTap,
child: Center(
child: AnimatedSwitcher(
duration: reduceMotion
? Duration.zero
: const Duration(milliseconds: 140),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeOutCubic,
child: busy
? BuzzLoadingIndicator(
key: const ValueKey('image-camera-capturing'),
size: 24,
color: context.colors.onSurface,
semanticLabel: 'Taking photo',
)
: Container(
key: const ValueKey('image-camera-shutter-icon'),
width: _shutterCoreSize,
height: _shutterCoreSize,
decoration: BoxDecoration(
color: context.colors.onSurface,
shape: BoxShape.circle,
border: Border.all(
color: context.colors.surface,
width: 1.5,
),
),
),
),
),
),
),
);
if (isIos) return content;
return Semantics(
label: 'Take photo',
button: true,
enabled: onTap != null,
child: ExcludeSemantics(child: content),
);
}
}

View File

@@ -115,14 +115,12 @@ final class ProfileAnimatedAvatarDraft extends ProfileAvatarDraft {
}
final existing = _uploadedUrl;
if (existing != null) return existing;
// Cache each content-addressed part independently. Upload sequentially so
// relays configured to allow only one in-flight media request can accept an
// animated avatar in a single Save attempt.
final upload = _uploadPoster(service).then(
(poster) => _uploadAnimation(
service,
).then((animation) => buildAnimatedAvatarUrl(poster.url, animation.url)),
);
// Cache each content-addressed part independently. If one request fails,
// retry only that part so a successful counterpart remains attached to
// this draft instead of becoming an abandoned duplicate.
final upload = Future.wait(
[_uploadPoster(service), _uploadAnimation(service)],
).then((uploads) => buildAnimatedAvatarUrl(uploads[0].url, uploads[1].url));
_uploadedUrl = upload;
try {
return await upload;

View File

@@ -18,12 +18,15 @@ import '../../shared/theme/theme.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/ios_native_segmented_control.dart';
import '../../shared/widgets/ios_glass_navigation_button.dart';
import '../../shared/widgets/ios_native_skin_tone_control.dart';
import '../../shared/widgets/playing_avatar_image.dart';
import 'animated_avatar_capture.dart';
import 'camera_disposal_barrier.dart';
import 'avatar_background_grid.dart';
import 'avatar_editor_option_button.dart';
import 'emoji_avatar_tile.dart';
import 'image_avatar_capture.dart';
import 'profile_avatar_crop_page.dart';
import 'profile_avatar_draft.dart';
@@ -79,7 +82,9 @@ class ProfileAvatarEditor extends HookConsumerWidget {
required this.onModeChanged,
required this.onDraftChanged,
required this.onAnimatedPrepareChanged,
required this.onImageCameraActiveChanged,
this.animatedCaptureBuilder,
this.imageCaptureBuilder,
});
/// The avatar URL shown until the user selects a new draft.
@@ -107,9 +112,15 @@ class ProfileAvatarEditor extends HookConsumerWidget {
final ValueChanged<Future<ProfileAvatarDraft?> Function()?>
onAnimatedPrepareChanged;
/// Reports whether the inline still camera currently owns the image editor.
final ValueChanged<bool> onImageCameraActiveChanged;
/// Overrides the animated capture surface, primarily for tests.
final AnimatedAvatarCaptureBuilder? animatedCaptureBuilder;
/// Overrides the still-image capture surface, primarily for tests.
final ImageAvatarCaptureBuilder? imageCaptureBuilder;
@override
Widget build(BuildContext context, WidgetRef ref) {
final reduceMotion = MediaQuery.disableAnimationsOf(context);
@@ -128,6 +139,8 @@ class ProfileAvatarEditor extends HookConsumerWidget {
final emojiSection = useState(_EmojiEditorSection.emoji);
final emojiPreviewKey = useState(0);
final isPickingImage = useState(false);
final isCapturingImage = useState(false);
final cameraDisposal = useRef(CameraDisposalBarrier());
final imageSelectionGeneration = useRef(0);
final currentMode = useRef(mode)..value = mode;
final error = useState<String?>(null);
@@ -154,6 +167,8 @@ class ProfileAvatarEditor extends HookConsumerWidget {
if (mode == ProfileAvatarMode.image) {
imageSelectionGeneration.value++;
isPickingImage.value = false;
isCapturingImage.value = false;
onImageCameraActiveChanged(false);
}
if (!reduceMotion) modeTransitionController.value = 0;
onModeChanged(nextMode);
@@ -180,7 +195,7 @@ class ProfileAvatarEditor extends HookConsumerWidget {
);
}
Future<void> selectImage({required bool camera}) async {
Future<void> selectGalleryImage() async {
if (isPickingImage.value) return;
final operation = ++imageSelectionGeneration.value;
bool isCurrentOperation() =>
@@ -192,9 +207,7 @@ class ProfileAvatarEditor extends HookConsumerWidget {
try {
final service = ref.read(mediaUploadServiceProvider);
unawaited(HapticFeedback.lightImpact());
final picked = camera
? await service.captureImage()
: await service.pickGalleryImage();
final picked = await service.pickGalleryImage();
if (picked == null || !isCurrentOperation()) return;
final preparedPhoto = await service.prepareImageBytes(picked);
if (!context.mounted || !isCurrentOperation()) return;
@@ -216,6 +229,16 @@ class ProfileAvatarEditor extends HookConsumerWidget {
}
}
void closeImageCamera() {
isCapturingImage.value = false;
onImageCameraActiveChanged(false);
}
void acceptCameraImage(Uint8List bytes) {
onDraftChanged(ProfileImageAvatarDraft(bytes));
closeImageCamera();
}
final previewUrl = switch (draft) {
ProfileUrlAvatarDraft(:final url) => url,
_ => currentAvatarUrl,
@@ -308,9 +331,7 @@ class ProfileAvatarEditor extends HookConsumerWidget {
);
final requestedShift = mode != ProfileAvatarMode.emoji
? 0.0
: emojiSection.value == _EmojiEditorSection.emoji
? _emojiPickerPreviewShift
: avatarBackgroundPreviewShift;
: _emojiPickerPreviewShift;
final previewShift = min(requestedShift, maximumShift);
final previewTop = basePreviewTop - previewShift;
final returningToEmoji =
@@ -332,20 +353,46 @@ class ProfileAvatarEditor extends HookConsumerWidget {
}
final fixedContentTop =
previewTop + _previewBlockSize + _previewControlGap;
final imageCameraTop =
basePreviewTop +
_previewBlockSize / 2 -
imageAvatarCameraPreviewSize / 2;
final modeTop = mode == ProfileAvatarMode.animated
? basePreviewTop
: mode == ProfileAvatarMode.image && isCapturingImage.value
? imageCameraTop
: fixedContentTop;
final modeHeight = max(
0.0,
viewportHeight - _editorControlsBottom - modeTop,
);
final modeContent = switch (mode) {
ProfileAvatarMode.image when isCapturingImage.value => KeyedSubtree(
key: const ValueKey('image-camera-mode'),
child:
imageCaptureBuilder?.call(
height: modeHeight,
onAccepted: acceptCameraImage,
onClosed: closeImageCamera,
) ??
ImageAvatarCapture(
height: modeHeight,
initialPreview: fixedPreview,
onAccepted: acceptCameraImage,
onClosed: closeImageCamera,
disposalBarrier: cameraDisposal.value,
),
),
ProfileAvatarMode.image => _ImageMode(
key: const ValueKey(0),
height: modeHeight,
isPicking: isPickingImage.value,
onCamera: () => unawaited(selectImage(camera: true)),
onLibrary: () => unawaited(selectImage(camera: false)),
onCamera: () {
error.value = null;
isCapturingImage.value = true;
onImageCameraActiveChanged(true);
},
onLibrary: () => unawaited(selectGalleryImage()),
),
ProfileAvatarMode.emoji => _EmojiMode(
key: const ValueKey(1),
@@ -376,6 +423,7 @@ class ProfileAvatarEditor extends HookConsumerWidget {
AnimatedAvatarCapture(
height: modeHeight,
onPrepareChanged: onAnimatedPrepareChanged,
disposalBarrier: cameraDisposal.value,
),
),
};
@@ -442,7 +490,7 @@ class ProfileAvatarEditor extends HookConsumerWidget {
),
),
),
if (fixedPreview != null)
if (fixedPreview != null && !isCapturingImage.value)
AnimatedPositioned(
key: const ValueKey('avatar-preview-position'),
curve: Curves.easeOutCubic,
@@ -479,7 +527,7 @@ class ProfileAvatarEditor extends HookConsumerWidget {
),
),
AnimatedPositioned(
duration: reduceMotion
duration: isCapturingImage.value || reduceMotion
? Duration.zero
: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
@@ -660,22 +708,22 @@ class _ImageMode extends StatelessWidget {
children: [
const Spacer(),
Expanded(
child: AvatarEditorOptionButton(
child: _ImageSourceOption(
key: const ValueKey('image-source-camera'),
icon: LucideIcons.camera,
iosIcon: IosGlassNavigationIcon.camera,
label: 'Camera',
selected: false,
onTap: isPicking ? null : onCamera,
labelMaxWidth: 96,
),
),
const SizedBox(width: Grid.half),
Expanded(
child: AvatarEditorOptionButton(
child: _ImageSourceOption(
key: const ValueKey('image-source-library'),
icon: LucideIcons.images,
iosIcon: IosGlassNavigationIcon.photoLibrary,
label: 'Photo Library',
selected: false,
onTap: isPicking ? null : onLibrary,
labelMaxWidth: 104,
),
@@ -687,3 +735,30 @@ class _ImageMode extends StatelessWidget {
),
);
}
class _ImageSourceOption extends StatelessWidget {
const _ImageSourceOption({
super.key,
required this.icon,
required this.iosIcon,
required this.label,
required this.onTap,
required this.labelMaxWidth,
});
final IconData icon;
final IosGlassNavigationIcon iosIcon;
final String label;
final VoidCallback? onTap;
final double labelMaxWidth;
@override
Widget build(BuildContext context) => AvatarEditorOptionButton(
icon: icon,
iosIcon: iosIcon,
label: label,
selected: false,
onTap: onTap,
labelMaxWidth: labelMaxWidth,
);
}

View File

@@ -83,11 +83,16 @@ class _EmojiMode extends HookConsumerWidget {
child: Opacity(
opacity: transitionProgress,
child: activeSection == _EmojiEditorSection.background
? AvatarBackgroundGrid(
key: const ValueKey('emoji-background-editor'),
selectedColor: selectedColor,
onColorSelected: onColorSelected,
colorKeyPrefix: 'emoji-avatar-color',
? Center(
key: const ValueKey(
'emoji-background-editor-alignment',
),
child: AvatarBackgroundGrid(
key: const ValueKey('emoji-background-editor'),
selectedColor: selectedColor,
onColorSelected: onColorSelected,
colorKeyPrefix: 'emoji-avatar-color',
),
)
: Column(
key: const ValueKey('emoji-glyph-editor'),
@@ -223,6 +228,7 @@ class _EmojiMode extends HookConsumerWidget {
child: AvatarEditorOptionButton(
key: const ValueKey('emoji-editor-background'),
icon: LucideIcons.palette,
iosIcon: IosGlassNavigationIcon.palette,
label: 'Background',
selected:
activeSection == _EmojiEditorSection.background,
@@ -236,6 +242,7 @@ class _EmojiMode extends HookConsumerWidget {
child: AvatarEditorOptionButton(
key: const ValueKey('emoji-editor-emoji'),
icon: LucideIcons.smile,
iosIcon: IosGlassNavigationIcon.emoji,
label: 'Emoji',
selected: activeSection == _EmojiEditorSection.emoji,
onTap: () =>

View File

@@ -21,11 +21,11 @@ import '../../shared/widgets/ios_glass_navigation_button.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/playing_avatar_image.dart';
import 'ios_profile_text_editor.dart';
import 'image_avatar_capture.dart';
import 'profile_avatar_editor.dart';
import 'profile_avatar_draft.dart';
import 'profile_provider.dart';
import 'profile_text_editor.dart';
import 'profile_text_edit_sheet.dart';
/// Edits the current user's public profile metadata.
class ProfileEditPage extends HookConsumerWidget {
@@ -34,6 +34,7 @@ class ProfileEditPage extends HookConsumerWidget {
super.key,
this.startInPhotoEditor = false,
this.animatedAvatarCaptureBuilder,
this.imageAvatarCaptureBuilder,
});
/// Opens directly into the photo editor when launched from Settings.
@@ -42,6 +43,9 @@ class ProfileEditPage extends HookConsumerWidget {
/// Overrides animated capture for focused integration tests.
final AnimatedAvatarCaptureBuilder? animatedAvatarCaptureBuilder;
/// Overrides still-image capture for focused integration tests.
final ImageAvatarCaptureBuilder? imageAvatarCaptureBuilder;
static const _avatarRadius = 64.0;
@override
@@ -62,6 +66,7 @@ class ProfileEditPage extends HookConsumerWidget {
useRef<Future<ProfileAvatarDraft?> Function()?>(null);
final avatarSaveError = useState<String?>(null);
final canPrepareAnimatedAvatar = useState(false);
final isImageCameraActive = useState(false);
final avatarMode = useState(ProfileAvatarMode.image);
final avatarTransition = useAnimationController(
duration: reduceMotion
@@ -87,11 +92,10 @@ class ProfileEditPage extends HookConsumerWidget {
bool multiline = false,
}) => showBuzzModalBottomSheet<void>(
context: context,
title: title,
isScrollControlled: true,
requestFocus: true,
showCloseButton: false,
builder: (_) => ProfileTextEditSheet(
title: title,
builder: (_) => _ProfileTextEditSheet(
initialValue: initialValue,
hintText: hintText,
multiline: multiline,
@@ -172,6 +176,7 @@ class ProfileEditPage extends HookConsumerWidget {
avatarEditConfig.value = null;
prepareAnimatedAvatar.value = null;
canPrepareAnimatedAvatar.value = false;
isImageCameraActive.value = false;
avatarMode.value = ProfileAvatarMode.image;
isClosingAvatar.value = false;
}
@@ -224,6 +229,19 @@ class ProfileEditPage extends HookConsumerWidget {
requireCurrentCommunity();
await ref.read(profileProvider.notifier).updateAvatarUrl(nextAvatar);
requireCurrentCommunity();
if (nextDraft is ProfileAnimatedAvatarDraft) {
ref
.read(profileAvatarHandoffProvider.notifier)
.show(
ProfileAvatarHandoff(
avatarUrl: nextAvatar,
animation: nextDraft.animation,
poster: nextDraft.poster,
),
);
} else {
ref.read(profileAvatarHandoffProvider.notifier).clearAny();
}
if (context.mounted) await closeAvatarEditor(whileSaving: true);
} on ProfileCommunityChangedException {
await discardStaleEditor();
@@ -243,6 +261,7 @@ class ProfileEditPage extends HookConsumerWidget {
final canSaveAvatar =
profileHydrated &&
!isImageCameraActive.value &&
(avatarMode.value == ProfileAvatarMode.animated
? canPrepareAnimatedAvatar.value
: avatarDraftMode.value == avatarMode.value &&
@@ -250,6 +269,7 @@ class ProfileEditPage extends HookConsumerWidget {
final activeDraft = avatarDraftMode.value == avatarMode.value
? avatarDraft.value
: null;
final avatarHandoff = ref.watch(profileAvatarHandoffProvider);
return PopScope(
canPop: !isEditingAvatar.value,
@@ -369,8 +389,12 @@ class ProfileEditPage extends HookConsumerWidget {
avatarDraftMode.value = null;
}
},
onImageCameraActiveChanged: (active) {
isImageCameraActive.value = active;
},
animatedCaptureBuilder:
animatedAvatarCaptureBuilder,
imageCaptureBuilder: imageAvatarCaptureBuilder,
),
),
),
@@ -410,6 +434,7 @@ class ProfileEditPage extends HookConsumerWidget {
children: [
_ProfilePhotoEditor(
profile: profile,
handoff: avatarHandoff,
onEditPhoto: profileHydrated ? openAvatarEditor : null,
),
AppListCard(
@@ -488,36 +513,57 @@ String _fieldValue(String? value) {
return trimmed.isEmpty ? 'Not set' : trimmed;
}
class _ProfilePhotoEditor extends StatelessWidget {
const _ProfilePhotoEditor({required this.profile, required this.onEditPhoto});
class _ProfilePhotoEditor extends ConsumerWidget {
const _ProfilePhotoEditor({
required this.profile,
required this.handoff,
required this.onEditPhoto,
});
final UserProfile? profile;
final ProfileAvatarHandoff? handoff;
final VoidCallback? onEditPhoto;
@override
Widget build(BuildContext context) => Column(
children: [
PlayingAvatarImage(
key: const ValueKey('profile-edit-avatar'),
imageUrl: profile?.avatarUrl,
radius: ProfileEditPage._avatarRadius,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
profile?.initial ?? '?',
style: context.textTheme.displaySmall?.copyWith(
color: context.colors.onPrimaryContainer,
Widget build(BuildContext context, WidgetRef ref) {
final activeHandoff = handoff?.avatarUrl == profile?.avatarUrl
? handoff
: null;
return Column(
children: [
PlayingAvatarImage(
key: const ValueKey('profile-edit-avatar'),
imageUrl: profile?.avatarUrl,
radius: ProfileEditPage._avatarRadius,
backgroundColor: context.colors.primaryContainer,
loadingImage: activeHandoff == null
? null
: MemoryImage(activeHandoff.animation),
loadingPosterImage: activeHandoff == null
? null
: MemoryImage(activeHandoff.poster),
onAnimationReady: activeHandoff == null
? null
: () => ref
.read(profileAvatarHandoffProvider.notifier)
.clear(activeHandoff.avatarUrl),
fallback: Text(
profile?.initial ?? '?',
style: context.textTheme.displaySmall?.copyWith(
color: context.colors.onPrimaryContainer,
),
),
),
),
const SizedBox(height: Grid.twelve),
_ProfileActionPill(
key: const ValueKey('profile-edit-photo-pill'),
semanticLabel: 'Edit profile photo',
label: 'Edit Photo',
onTap: onEditPhoto,
),
],
);
const SizedBox(height: Grid.twelve),
_ProfileActionPill(
key: const ValueKey('profile-edit-photo-pill'),
semanticLabel: 'Edit profile photo',
label: 'Edit Photo',
onTap: onEditPhoto,
),
],
);
}
}
class _ProfileActionPill extends StatelessWidget {
@@ -582,3 +628,89 @@ class _EditChevron extends StatelessWidget {
color: context.colors.onSurfaceVariant,
);
}
class _ProfileTextEditSheet extends HookWidget {
const _ProfileTextEditSheet({
required this.initialValue,
required this.hintText,
required this.multiline,
required this.onSave,
});
final String initialValue;
final String hintText;
final bool multiline;
final Future<void> Function(String value) onSave;
@override
Widget build(BuildContext context) {
final controller = useTextEditingController(text: initialValue);
useListenable(controller);
final isSaving = useState(false);
final error = useState<String?>(null);
final hasChanges = controller.text.trim() != initialValue.trim();
Future<void> save() async {
if (!hasChanges || isSaving.value) return;
isSaving.value = true;
error.value = null;
try {
await onSave(controller.text);
if (context.mounted) Navigator.of(context).pop();
} on ProfileCommunityChangedException {
if (context.mounted) Navigator.of(context).pop();
} catch (_) {
error.value = "We couldn't save this change. Try again.";
} finally {
if (context.mounted) isSaving.value = false;
}
}
return SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
key: const ValueKey('profile-field-input'),
controller: controller,
autofocus: true,
enabled: !isSaving.value,
minLines: multiline ? 4 : 1,
maxLines: multiline ? 6 : 1,
textCapitalization: TextCapitalization.sentences,
textInputAction: multiline
? TextInputAction.newline
: TextInputAction.done,
onSubmitted: multiline ? null : (_) => unawaited(save()),
decoration: InputDecoration(hintText: hintText),
),
if (error.value != null) ...[
const SizedBox(height: Grid.xxs),
Text(
error.value!,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
const SizedBox(height: Grid.xs),
FilledButton(
key: const ValueKey('profile-field-save'),
onPressed: hasChanges && !isSaving.value ? save : null,
child: Text(isSaving.value ? 'Saving…' : 'Save'),
),
],
),
),
);
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -17,6 +18,53 @@ class ProfileCommunityChangedException extends StateError {
: super('Profile update cancelled because the active community changed.');
}
/// Keeps a freshly saved animated avatar local until its remote first frame is
/// ready, preserving the editor-to-profile visual handoff.
@immutable
class ProfileAvatarHandoff {
/// Creates a local handoff for [avatarUrl].
const ProfileAvatarHandoff({
required this.avatarUrl,
required this.animation,
required this.poster,
});
/// The persisted animated-avatar descriptor this handoff belongs to.
final String avatarUrl;
/// The locally encoded animation shown while remote media warms up.
final Uint8List animation;
/// The locally encoded poster used when motion is disabled.
final Uint8List poster;
}
/// Owns the temporary local avatar shown across the save route transition.
class ProfileAvatarHandoffNotifier extends Notifier<ProfileAvatarHandoff?> {
@override
ProfileAvatarHandoff? build() {
ref.watch(relayConfigProvider);
return null;
}
/// Starts a handoff for a freshly published animated avatar.
void show(ProfileAvatarHandoff handoff) => state = handoff;
/// Clears the handoff once the matching remote animation is ready.
void clear(String avatarUrl) {
if (state?.avatarUrl == avatarUrl) state = null;
}
/// Clears a handoff superseded by a non-animated avatar save.
void clearAny() => state = null;
}
/// The freshly saved local animated avatar, while its remote copy warms up.
final profileAvatarHandoffProvider =
NotifierProvider<ProfileAvatarHandoffNotifier, ProfileAvatarHandoff?>(
ProfileAvatarHandoffNotifier.new,
);
/// The current user's profile (kind:0 metadata) loaded over the relay
/// WebSocket. Returns null when no nsec is configured or when the user has
/// not yet published a profile.

View File

@@ -3,13 +3,14 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/modal_presentation.dart';
import 'ios_profile_text_editor.dart';
import 'profile_provider.dart';
import 'profile_text_edit_sheet.dart';
/// Opens the current user's display-name editor from a profile action surface.
Future<void> showProfileDisplayNameEditor(BuildContext context) async {
@@ -123,11 +124,10 @@ Future<void> _showProfileTextEditor({
if (!context.mounted) return;
await showBuzzModalBottomSheet<void>(
context: context,
title: title,
isScrollControlled: true,
requestFocus: true,
showCloseButton: false,
builder: (_) => ProfileTextEditSheet(
title: title,
builder: (_) => _ProfileTextEditSheet(
initialValue: initialValue,
hintText: hintText,
multiline: multiline,
@@ -141,3 +141,89 @@ void _showSaveError(BuildContext context) {
const SnackBar(content: Text("We couldn't save this change. Try again.")),
);
}
class _ProfileTextEditSheet extends HookWidget {
const _ProfileTextEditSheet({
required this.initialValue,
required this.hintText,
required this.multiline,
required this.onSave,
});
final String initialValue;
final String hintText;
final bool multiline;
final Future<void> Function(String value) onSave;
@override
Widget build(BuildContext context) {
final controller = useTextEditingController(text: initialValue);
useListenable(controller);
final isSaving = useState(false);
final error = useState<String?>(null);
final hasChanges = controller.text.trim() != initialValue.trim();
Future<void> save() async {
if (!hasChanges || isSaving.value) return;
isSaving.value = true;
error.value = null;
try {
await onSave(controller.text);
if (context.mounted) Navigator.of(context).pop();
} on ProfileCommunityChangedException {
if (context.mounted) Navigator.of(context).pop();
} catch (_) {
error.value = "We couldn't save this change. Try again.";
} finally {
if (context.mounted) isSaving.value = false;
}
}
return SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
MediaQuery.viewInsetsOf(context).bottom + Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
key: const ValueKey('profile-field-input'),
controller: controller,
autofocus: true,
enabled: !isSaving.value,
minLines: multiline ? 4 : 1,
maxLines: multiline ? 6 : 1,
textCapitalization: TextCapitalization.sentences,
textInputAction: multiline
? TextInputAction.newline
: TextInputAction.done,
onSubmitted: multiline ? null : (_) => unawaited(save()),
decoration: InputDecoration(hintText: hintText),
),
if (error.value != null) ...[
const SizedBox(height: Grid.xxs),
Text(
error.value!,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
const SizedBox(height: Grid.xs),
FilledButton(
key: const ValueKey('profile-field-save'),
onPressed: hasChanges && !isSaving.value ? save : null,
child: Text(isSaving.value ? 'Saving…' : 'Save'),
),
],
),
),
);
}
}

View File

@@ -8,6 +8,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/animated_avatar.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/relay/media_image.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/anchored_popover_menu.dart';
@@ -34,6 +35,11 @@ class SettingsProfileHeader extends HookConsumerWidget {
final hasStatus = status != null && !status.isEmpty;
final presence = ref.watch(presenceProvider).value ?? 'offline';
final animatedAvatar = parseAnimatedAvatarUrl(profile?.avatarUrl);
final animatedPosterUrl = animatedAvatar?.posterUrl;
final handoff = ref.watch(profileAvatarHandoffProvider);
final activeHandoff = handoff?.avatarUrl == profile?.avatarUrl
? handoff
: null;
final stoppedAnimationUrl = useState<String?>(null);
final avatarUrl = animatedAvatar == null
? profile?.avatarUrl
@@ -71,10 +77,62 @@ class SettingsProfileHeader extends HookConsumerWidget {
key: ValueKey(animatedAvatar.animationUrl),
descriptor: animatedAvatar,
fallback: _AvatarFallback(initial: profile?.initial),
loadingImage: activeHandoff == null
? null
: MemoryImage(activeHandoff.animation),
onAnimationReady: activeHandoff == null
? null
: () => ref
.read(profileAvatarHandoffProvider.notifier)
.clear(activeHandoff.avatarUrl),
)
: AvatarImageContent(
: activeHandoff == null || animatedPosterUrl == null
? AvatarImageContent(
imageUrl: avatarUrl,
fallback: _AvatarFallback(initial: profile?.initial),
)
: Stack(
fit: StackFit.expand,
children: [
Image(
image: MemoryImage(activeHandoff.poster),
fit: BoxFit.cover,
gaplessPlayback: true,
),
Offstage(
offstage: true,
child: MediaImage(
key: ValueKey(
'settings-profile-paused-handoff-${activeHandoff.avatarUrl}',
),
url: animatedPosterUrl,
fit: BoxFit.cover,
errorBuilder: (_, _, _) =>
const SizedBox.shrink(),
frameBuilder:
(
context,
child,
frame,
wasSynchronouslyLoaded,
) {
if (wasSynchronouslyLoaded ||
frame != null) {
WidgetsBinding.instance
.addPostFrameCallback((_) {
ref
.read(
profileAvatarHandoffProvider
.notifier,
)
.clear(activeHandoff.avatarUrl);
});
}
return child;
},
),
),
],
),
),
),

View File

@@ -9,7 +9,18 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import '../theme/theme.dart';
/// The navigation glyph displayed by [IosGlassNavigationButton].
enum IosGlassNavigationIcon { back, close }
enum IosGlassNavigationIcon {
back,
close,
camera,
photoLibrary,
palette,
emoji,
person,
frame,
rotateCamera,
shutter,
}
/// Leading width used by iOS channel-style headers.
const iosGlassChannelHeaderLeadingWidth = 58.0;
@@ -30,22 +41,57 @@ class IosGlassNavigationButton extends HookWidget {
required this.icon,
required this.semanticLabel,
required this.onPressed,
this.label,
this.width = 48,
this.height = 48,
this.controlSize = 40,
this.fillWidth = false,
this.buttonCenterX,
this.foregroundColor,
this.isBusy = false,
this.isSelected = false,
this.nativeViewSuppressed,
});
static const viewType = 'buzz/navigation_glass';
/// The SF Symbol-style glyph rendered by the native control.
final IosGlassNavigationIcon icon;
/// Optional text rendered by the native control instead of [icon].
final String? label;
/// Accessibility label exposed by the native view or Flutter fallback.
final String semanticLabel;
/// Invoked when the enabled control is activated.
final VoidCallback? onPressed;
/// Width of the platform-view hit target.
final double width;
/// Height of the platform-view hit target.
final double height;
/// Diameter of the visual glass control inside its hit target.
final double controlSize;
/// Whether the native visual control fills the available width.
final bool fillWidth;
/// Horizontal center for the visual control within its hit target.
final double? buttonCenterX;
/// Optional foreground tint for the native control and Flutter fallback.
final Color? foregroundColor;
/// Whether the native control presents its busy state.
final bool isBusy;
/// Whether the native control exposes its selected state.
final bool isSelected;
/// When true, substitutes an accessible Flutter control for the native view.
final ValueListenable<bool>? nativeViewSuppressed;
@override
@@ -69,19 +115,44 @@ class IosGlassNavigationButton extends HookWidget {
return () => channel.setMethodCallHandler(null);
}, [nativeChannel.value]);
useEffect(
() {
final channel = nativeChannel.value;
if (channel != null) {
unawaited(
channel.invokeMethod<void>('setAppearance', <String, Object>{
'brightness': brightness,
'foregroundColor': foregroundValue,
'enabled': enabled,
'busy': isBusy,
'selected': isSelected,
}),
);
}
return null;
},
[
nativeChannel.value,
brightness,
foregroundValue,
enabled,
isBusy,
isSelected,
],
);
useEffect(() {
final channel = nativeChannel.value;
if (channel != null) {
unawaited(
channel.invokeMethod<void>('setAppearance', <String, Object>{
'brightness': brightness,
'foregroundColor': foregroundValue,
'enabled': enabled,
}),
);
final content = <String, Object>{
'icon': icon.name,
'accessibilityLabel': semanticLabel,
};
if (label != null) content['label'] = label!;
unawaited(channel.invokeMethod<void>('setContent', content));
}
return null;
}, [nativeChannel.value, brightness, foregroundValue, enabled]);
}, [nativeChannel.value, icon, label, semanticLabel]);
Widget buildControl({required bool suppressNativeView}) {
if (suppressNativeView) {
@@ -90,6 +161,7 @@ class IosGlassNavigationButton extends HookWidget {
container: true,
button: true,
enabled: enabled,
selected: isSelected,
label: semanticLabel,
onTap: onPressed,
child: ExcludeSemantics(
@@ -97,10 +169,10 @@ class IosGlassNavigationButton extends HookWidget {
key: const ValueKey('ios-glass-navigation-flutter-fallback'),
children: [
Positioned(
left: resolvedButtonCenterX - 20,
top: (height - 40) / 2,
width: 40,
height: 40,
left: resolvedButtonCenterX - controlSize / 2,
top: (height - controlSize) / 2,
width: controlSize,
height: controlSize,
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface.withValues(alpha: 0.72),
@@ -111,13 +183,52 @@ class IosGlassNavigationButton extends HookWidget {
),
),
),
child: Icon(
icon == IosGlassNavigationIcon.back
? Icons.arrow_back_ios_new_rounded
: Icons.close_rounded,
size: 20,
color: effectiveForeground,
),
child: isBusy
? Center(
child: SizedBox.square(
dimension: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: effectiveForeground,
),
),
)
: label != null
? Text(
label!,
maxLines: 1,
style: context.textTheme.labelMedium?.copyWith(
color: effectiveForeground,
fontWeight: FontWeight.w600,
),
)
: Icon(
switch (icon) {
IosGlassNavigationIcon.back =>
Icons.arrow_back_ios_new_rounded,
IosGlassNavigationIcon.close =>
Icons.close_rounded,
IosGlassNavigationIcon.camera =>
Icons.camera_alt_rounded,
IosGlassNavigationIcon.photoLibrary =>
Icons.photo_library_rounded,
IosGlassNavigationIcon.palette =>
Icons.palette_rounded,
IosGlassNavigationIcon.emoji =>
Icons.emoji_emotions_rounded,
IosGlassNavigationIcon.person =>
Icons.person_rounded,
IosGlassNavigationIcon.frame =>
Icons.photo_size_select_actual_rounded,
IosGlassNavigationIcon.rotateCamera =>
Icons.cameraswitch_rounded,
IosGlassNavigationIcon.shutter => Icons.circle,
},
size: icon == IosGlassNavigationIcon.shutter
? controlSize * 0.72
: 22,
color: effectiveForeground,
),
),
),
],
@@ -125,19 +236,26 @@ class IosGlassNavigationButton extends HookWidget {
),
);
}
final creationParams = <String, Object>{
'icon': icon.name,
'accessibilityLabel': semanticLabel,
'brightness': brightness,
'foregroundColor': foregroundValue,
'enabled': enabled,
'busy': isBusy,
'selected': isSelected,
'controlSize': controlSize,
'controlWidth': controlSize,
'fillWidth': fillWidth,
'buttonCenterX': buttonCenterX ?? width / 2,
'hitTargetWidth': width,
'hitTargetHeight': height,
};
if (label != null) creationParams['label'] = label!;
return UiKitView(
viewType: viewType,
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
creationParams: <String, Object>{
'icon': icon.name,
'accessibilityLabel': semanticLabel,
'brightness': brightness,
'foregroundColor': foregroundValue,
'enabled': enabled,
'buttonCenterX': buttonCenterX ?? width / 2,
'hitTargetWidth': width,
'hitTargetHeight': height,
},
creationParams: creationParams,
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: (viewId) {
nativeChannel.value = MethodChannel('$viewType/$viewId');

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../relay/media_image.dart';
import '../animated_avatar.dart';
import 'avatar_image.dart';
import 'progressive_animated_avatar.dart';
@@ -13,6 +14,9 @@ class PlayingAvatarImage extends StatelessWidget {
required this.radius,
required this.fallback,
this.backgroundColor,
this.loadingImage,
this.loadingPosterImage,
this.onAnimationReady,
});
/// The still-image URL or encoded animated-avatar descriptor.
@@ -24,12 +28,60 @@ class PlayingAvatarImage extends StatelessWidget {
/// Optional background shown behind still-image content.
final Color? backgroundColor;
/// A local image shown while the persisted animation warms up.
final ImageProvider? loadingImage;
/// A local poster shown when motion is disabled while remote media loads.
final ImageProvider? loadingPosterImage;
/// Called after persisted avatar media produces its first frame.
final VoidCallback? onAnimationReady;
/// Content shown while media is unavailable or still loading.
final Widget fallback;
@override
Widget build(BuildContext context) {
final descriptor = parseAnimatedAvatarUrl(imageUrl);
if (descriptor != null &&
MediaQuery.disableAnimationsOf(context) &&
loadingPosterImage != null) {
return CircleAvatar(
radius: radius,
backgroundColor: Colors.transparent,
child: ClipOval(
child: SizedBox.square(
dimension: radius * 2,
child: Stack(
fit: StackFit.expand,
children: [
Image(
image: loadingPosterImage!,
fit: BoxFit.cover,
gaplessPlayback: true,
),
Offstage(
offstage: true,
child: MediaImage(
url: descriptor.posterUrl,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
onAnimationReady?.call();
});
}
return child;
},
),
),
],
),
),
),
);
}
if (descriptor == null || MediaQuery.disableAnimationsOf(context)) {
return AvatarImage(
imageUrl: descriptor?.posterUrl ?? imageUrl,
@@ -48,6 +100,8 @@ class PlayingAvatarImage extends StatelessWidget {
child: ProgressiveAnimatedAvatar(
descriptor: descriptor,
fallback: fallback,
loadingImage: loadingImage,
onAnimationReady: onAnimationReady,
),
),
),

View File

@@ -15,12 +15,20 @@ class ProgressiveAnimatedAvatar extends HookWidget {
required this.descriptor,
required this.fallback,
this.fit = BoxFit.cover,
this.loadingImage,
this.onAnimationReady,
});
final AnimatedAvatarDescriptor descriptor;
final Widget fallback;
final BoxFit fit;
/// A local image shown instead of the poster while remote media warms up.
final ImageProvider? loadingImage;
/// Called after the remote animation has produced its first frame.
final VoidCallback? onAnimationReady;
@override
Widget build(BuildContext context) {
final readyAnimationUrl = useState<String?>(null);
@@ -42,6 +50,7 @@ class ProgressiveAnimatedAvatar extends HookWidget {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
readyAnimationUrl.value = descriptor.animationUrl;
onAnimationReady?.call();
}
});
}
@@ -60,12 +69,20 @@ class ProgressiveAnimatedAvatar extends HookWidget {
return Stack(
fit: StackFit.expand,
children: [
AvatarImageContent(
key: const ValueKey('progressive-animated-avatar-poster'),
imageUrl: descriptor.posterUrl,
fallback: fallback,
fit: fit,
),
if (loadingImage case final image?)
Image(
key: const ValueKey('progressive-animated-avatar-local-handoff'),
image: image,
fit: fit,
gaplessPlayback: true,
)
else
AvatarImageContent(
key: const ValueKey('progressive-animated-avatar-poster'),
imageUrl: descriptor.posterUrl,
fallback: fallback,
fit: fit,
),
Offstage(
key: const ValueKey('progressive-animated-avatar-animation-loading'),
offstage: true,

View File

@@ -162,7 +162,7 @@ packages:
source: hosted
version: "0.10.2"
camera_platform_interface:
dependency: transitive
dependency: "direct dev"
description:
name: camera_platform_interface
sha256: "4524ca6eb4176b066864036ad4fe02c3e4863e63b77eadc21a5bf56824f43498"

View File

@@ -51,6 +51,7 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
camera_platform_interface: ^2.13.1
crypto: ^3.0.7
custom_lint: ^0.8.0
riverpod_lint: ^3.1.0

View File

@@ -1,12 +1,15 @@
import 'dart:io';
import 'dart:async';
import 'dart:ui' show SemanticsAction;
import 'package:buzz/features/profile/animated_avatar_orientation.dart';
import 'package:camera_platform_interface/camera_platform_interface.dart';
import 'package:buzz/features/profile/animated_avatar_capture.dart';
import 'package:buzz/features/profile/animated_avatar_orientation.dart';
import 'package:buzz/features/profile/camera_disposal_barrier.dart';
import 'package:buzz/features/profile/image_avatar_capture.dart';
import 'package:buzz/features/profile/profile_avatar_draft.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -28,6 +31,18 @@ void main() {
expect(center.r, 255);
});
test('encoded poster preserves avatar scales above one', () {
final source = image.Image(width: 256, height: 256, numChannels: 4);
image.fill(source, color: image.ColorRgba8(255, 0, 0, 255));
final poster = image.decodePng(
encodeAnimatedAvatarPoster(frame: image.encodePng(source), scale: 1.5),
)!;
expect(poster.getPixel(8, 8).r, 255);
expect(poster.getPixel(247, 247).r, 255);
});
test('capture frame workspaces are isolated', () async {
final first = await createAnimatedAvatarFrameDirectory(
parent: Directory.systemTemp,
@@ -43,46 +58,238 @@ void main() {
expect(first.path, isNot(second.path));
});
group('animatedAvatarFrameRotationDegrees', () {
test('compensates front camera frames for every device orientation', () {
const expected = {
DeviceOrientation.portraitUp: 270,
DeviceOrientation.landscapeRight: 180,
DeviceOrientation.portraitDown: 90,
DeviceOrientation.landscapeLeft: 0,
};
test('accounts for device orientation and lens direction', () {
expect(
animatedAvatarFrameRotationDegrees(
sensorOrientation: 270,
deviceOrientation: DeviceOrientation.landscapeRight,
lensDirection: CameraLensDirection.front,
),
180,
);
expect(
animatedAvatarFrameRotationDegrees(
sensorOrientation: 270,
deviceOrientation: DeviceOrientation.landscapeRight,
lensDirection: CameraLensDirection.back,
),
0,
);
});
for (final entry in expected.entries) {
expect(
animatedAvatarFrameRotationDegrees(
sensorOrientation: 270,
deviceOrientation: entry.key,
lensDirection: CameraLensDirection.front,
),
entry.value,
);
}
testWidgets('animated capture releases a failed orientation-lock candidate', (
tester,
) async {
final platform = _TestCameraPlatform(failLockForCameraIds: {1});
final previousPlatform = CameraPlatform.instance;
CameraPlatform.instance = platform;
addTearDown(() {
CameraPlatform.instance = previousPlatform;
});
test('compensates back camera frames for every device orientation', () {
const expected = {
DeviceOrientation.portraitUp: 90,
DeviceOrientation.landscapeRight: 180,
DeviceOrientation.portraitDown: 270,
DeviceOrientation.landscapeLeft: 0,
};
for (final entry in expected.entries) {
expect(
animatedAvatarFrameRotationDegrees(
sensorOrientation: 90,
deviceOrientation: entry.key,
lensDirection: CameraLensDirection.back,
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: SizedBox(
height: 600,
child: AnimatedAvatarCapture(
height: 600,
onPrepareChanged: (_) {},
),
),
),
entry.value,
);
}
});
),
),
);
await tester.pump();
await tester.pump();
expect(platform.createdCameraIds, [1]);
expect(platform.disposedCameraIds, [1]);
expect(find.text('Could not access the camera.'), findsOneWidget);
final error = find.byWidgetPredicate(
(widget) => widget is Semantics && widget.properties.liveRegion == true,
);
expect(error, findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pump();
});
testWidgets('animated capture waits for disposal before lifecycle resume', (
tester,
) async {
final platform = _TestCameraPlatform(blockDisposeForCameraIds: {1});
final previousPlatform = CameraPlatform.instance;
CameraPlatform.instance = platform;
addTearDown(() => CameraPlatform.instance = previousPlatform);
final lifecycle = _TestLifecycleNotifier();
await tester.pumpWidget(
ProviderScope(
overrides: [appLifecycleProvider.overrideWith(() => lifecycle)],
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: SizedBox(
height: 600,
child: AnimatedAvatarCapture(
height: 600,
onPrepareChanged: (_) {},
),
),
),
),
),
);
await tester.pump();
await tester.pump();
expect(platform.createdCameraIds, [1]);
lifecycle.setLifecycle(AppLifecycleState.paused);
await tester.pump();
expect(platform.disposedCameraIds, [1]);
lifecycle.setLifecycle(AppLifecycleState.resumed);
await tester.pump();
await tester.pump();
expect(platform.createdCameraIds, [1]);
platform.failDispose(1);
await tester.pump();
await tester.pump();
expect(platform.createdCameraIds, [1, 2]);
final record = tester.widget<InkWell>(
find.byKey(const ValueKey('animated-avatar-record')),
);
expect(record.onTap, isNotNull);
await tester.pumpWidget(const SizedBox());
await tester.pump();
});
testWidgets('waits for an in-flight camera before lifecycle replacement', (
tester,
) async {
final platform = _TestCameraPlatform(
blockDisposeForCameraIds: {1},
blockInitializeForCameraIds: {1},
);
final previousPlatform = CameraPlatform.instance;
CameraPlatform.instance = platform;
addTearDown(() => CameraPlatform.instance = previousPlatform);
final lifecycle = _TestLifecycleNotifier();
await tester.pumpWidget(
ProviderScope(
overrides: [appLifecycleProvider.overrideWith(() => lifecycle)],
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: SizedBox(
height: 600,
child: AnimatedAvatarCapture(
height: 600,
onPrepareChanged: (_) {},
),
),
),
),
),
);
await tester.pump();
await tester.pump();
expect(platform.createdCameraIds, [1]);
lifecycle.setLifecycle(AppLifecycleState.paused);
await tester.pump();
lifecycle.setLifecycle(AppLifecycleState.resumed);
await tester.pump();
await tester.pump();
expect(platform.createdCameraIds, [1]);
platform.completeInitialize(1);
await tester.pump();
await tester.pump();
expect(platform.disposedCameraIds, [1]);
expect(platform.createdCameraIds, [1]);
platform.completeDispose(1);
await tester.pump();
await tester.pump();
expect(platform.createdCameraIds, [1, 2]);
final record = tester.widget<InkWell>(
find.byKey(const ValueKey('animated-avatar-record')),
);
expect(record.onTap, isNotNull);
await tester.pumpWidget(const SizedBox());
await tester.pump();
});
testWidgets('shared disposal serializes camera-mode switches', (
tester,
) async {
for (final startsAnimated in [false, true]) {
final platform = _TestCameraPlatform(blockDisposeForCameraIds: {1});
final previousPlatform = CameraPlatform.instance;
CameraPlatform.instance = platform;
final barrier = CameraDisposalBarrier();
var animated = startsAnimated;
late StateSetter setMode;
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) {
setMode = setState;
return SizedBox(
height: 600,
child: animated
? AnimatedAvatarCapture(
height: 600,
onPrepareChanged: (_) {},
disposalBarrier: barrier,
)
: ImageAvatarCapture(
height: 600,
onAccepted: (_) {},
onClosed: () {},
loadCameras: platform.availableCameras,
disposalBarrier: barrier,
),
);
},
),
),
),
),
);
await tester.pump();
await tester.pump();
expect(platform.createdCameraIds, [1]);
setMode(() => animated = !animated);
await tester.pump();
await tester.pump();
expect(platform.disposedCameraIds, [1]);
expect(platform.createdCameraIds, [1]);
platform.completeDispose(1);
await tester.pump();
await tester.pump();
expect(platform.createdCameraIds, [1, 2]);
await tester.pumpWidget(const SizedBox());
await tester.pump();
CameraPlatform.instance = previousPlatform;
}
});
testWidgets('completed review frames survive lifecycle changes', (
@@ -218,6 +425,187 @@ void main() {
expect(tester.getSemantics(position).value, '10 horizontal, 0 vertical');
semantics.dispose();
});
testWidgets('an existing Save callback uses the latest cutout position', (
tester,
) async {
final source = image.Image(width: 256, height: 256, numChannels: 4);
image.fillRect(
source,
x1: 108,
y1: 108,
x2: 147,
y2: 147,
color: image.ColorRgba8(0, 255, 0, 255),
);
final frame = image.encodePng(source);
Future<ProfileAvatarDraft?> Function()? prepare;
await tester.pumpWidget(
ProviderScope(
overrides: [
appLifecycleProvider.overrideWith(_TestLifecycleNotifier.new),
],
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: AnimatedAvatarCapture(
height: 600,
initialFrames: [frame],
onPrepareChanged: (value) => prepare = value,
),
),
),
),
);
await tester.pump();
final originalPrepare = prepare!;
final centered = await tester.runAsync(originalPrepare);
final positionWidget = tester.widget<Semantics>(
find.byWidgetPredicate(
(widget) =>
widget is Semantics && widget.properties.label == 'Avatar position',
),
);
positionWidget.properties.customSemanticsActions!.entries
.firstWhere((entry) => entry.key.label == 'Move right')
.value();
await tester.pump();
final moved = await tester.runAsync(originalPrepare);
expect(
_greenCenterX((moved! as ProfileAnimatedAvatarDraft).poster),
greaterThan(
_greenCenterX((centered! as ProfileAnimatedAvatarDraft).poster) + 3,
),
);
});
}
double _greenCenterX(Uint8List bytes) {
final decoded = image.decodePng(bytes)!;
final matchingX = <int>[];
for (final pixel in decoded) {
if (pixel.g > 200 && pixel.r < 30 && pixel.b < 30) {
matchingX.add(pixel.x);
}
}
return matchingX.reduce((left, right) => left + right) / matchingX.length;
}
class _TestCameraPlatform extends CameraPlatform {
_TestCameraPlatform({
Set<int>? failLockForCameraIds,
Set<int>? blockDisposeForCameraIds,
Set<int>? blockInitializeForCameraIds,
}) : _failLockForCameraIds = failLockForCameraIds ?? const {},
_blockDisposeForCameraIds = blockDisposeForCameraIds ?? const {},
_blockInitializeForCameraIds = blockInitializeForCameraIds ?? const {};
final Set<int> _failLockForCameraIds;
final Set<int> _blockDisposeForCameraIds;
final Set<int> _blockInitializeForCameraIds;
final _disposeCompleters = <int, Completer<void>>{};
final _initializeCompleters = <int, Completer<void>>{};
final _initializedControllers =
<int, StreamController<CameraInitializedEvent>>{};
final _errorControllers = <int, StreamController<CameraErrorEvent>>{};
final _orientationController =
StreamController<DeviceOrientationChangedEvent>.broadcast();
final createdCameraIds = <int>[];
final disposedCameraIds = <int>[];
var _nextCameraId = 1;
@override
Future<List<CameraDescription>> availableCameras() async => const [
CameraDescription(
name: 'front',
lensDirection: CameraLensDirection.front,
sensorOrientation: 0,
),
];
@override
Future<int> createCameraWithSettings(
CameraDescription description,
MediaSettings mediaSettings,
) async {
final cameraId = _nextCameraId++;
createdCameraIds.add(cameraId);
_initializedControllers[cameraId] =
StreamController<CameraInitializedEvent>.broadcast();
_errorControllers[cameraId] =
StreamController<CameraErrorEvent>.broadcast();
return cameraId;
}
@override
Stream<CameraInitializedEvent> onCameraInitialized(int cameraId) =>
_initializedControllers[cameraId]!.stream.asBroadcastStream();
@override
Stream<CameraErrorEvent> onCameraError(int cameraId) =>
_errorControllers[cameraId]!.stream.asBroadcastStream();
@override
Stream<DeviceOrientationChangedEvent> onDeviceOrientationChanged() =>
_orientationController.stream;
@override
Future<void> initializeCamera(
int cameraId, {
ImageFormatGroup imageFormatGroup = ImageFormatGroup.unknown,
}) async {
if (_blockInitializeForCameraIds.contains(cameraId)) {
await (_initializeCompleters[cameraId] ??= Completer<void>()).future;
}
_initializedControllers[cameraId]!.add(
CameraInitializedEvent(
cameraId,
640,
480,
ExposureMode.auto,
false,
FocusMode.auto,
false,
),
);
}
@override
Future<void> lockCaptureOrientation(
int cameraId,
DeviceOrientation orientation,
) async {
if (_failLockForCameraIds.contains(cameraId)) {
throw PlatformException(code: 'orientation-failed');
}
}
@override
Widget buildPreview(int cameraId) => const SizedBox.expand();
@override
Future<void> dispose(int cameraId) async {
disposedCameraIds.add(cameraId);
if (_blockDisposeForCameraIds.contains(cameraId)) {
await (_disposeCompleters[cameraId] ??= Completer<void>()).future;
}
}
void completeInitialize(int cameraId) {
_initializeCompleters[cameraId]!.complete();
}
void failDispose(int cameraId) {
_disposeCompleters[cameraId]!.completeError(
PlatformException(code: 'dispose-failed'),
);
}
void completeDispose(int cameraId) {
_disposeCompleters[cameraId]!.complete();
}
}
class _TestLifecycleNotifier extends AppLifecycleNotifier {

View File

@@ -0,0 +1,58 @@
import 'dart:async';
import 'package:buzz/features/profile/camera_disposal_barrier.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('failed disposal settles before a replacement camera release', () async {
final barrier = CameraDisposalBarrier();
final firstDispose = Completer<void>();
final secondDispose = Completer<void>();
var secondStarted = false;
final first = barrier.release(() => firstDispose.future);
final second = barrier.release(() {
secondStarted = true;
return secondDispose.future;
});
expect(secondStarted, isFalse);
firstDispose.completeError(StateError('native camera teardown failed'));
await first;
await Future<void>.delayed(Duration.zero);
expect(secondStarted, isTrue);
secondDispose.complete();
await second;
await barrier.settled;
});
test(
'serializes replacement releases without overlapping ownership',
() async {
final barrier = CameraDisposalBarrier();
final firstDispose = Completer<void>();
var activeDisposals = 0;
var overlapped = false;
final first = barrier.release(() async {
activeDisposals++;
if (activeDisposals > 1) overlapped = true;
await firstDispose.future;
activeDisposals--;
});
final second = barrier.release(() async {
activeDisposals++;
if (activeDisposals > 1) overlapped = true;
activeDisposals--;
});
await Future<void>.delayed(Duration.zero);
expect(activeDisposals, 1);
firstDispose.complete();
await Future.wait([first, second]);
expect(overlapped, isFalse);
},
);
}

View File

@@ -0,0 +1,248 @@
import 'dart:async';
import 'package:buzz/features/profile/image_avatar_capture.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:camera_platform_interface/camera_platform_interface.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
void main() {
testWidgets('resumes with a replacement after native disposal fails', (
tester,
) async {
final platform = _TestCameraPlatform(blockDisposeForCameraIds: {1});
final previousPlatform = CameraPlatform.instance;
CameraPlatform.instance = platform;
addTearDown(() => CameraPlatform.instance = previousPlatform);
final lifecycle = _TestLifecycleNotifier();
await tester.pumpWidget(
ProviderScope(
overrides: [appLifecycleProvider.overrideWith(() => lifecycle)],
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: SizedBox(
height: 400,
child: ImageAvatarCapture(
height: 400,
onAccepted: (_) {},
onClosed: () {},
loadCameras: platform.availableCameras,
),
),
),
),
),
);
await tester.pump();
await tester.pump();
expect(platform.createdCameraIds, [1]);
lifecycle.setLifecycle(AppLifecycleState.paused);
await tester.pump();
expect(platform.disposedCameraIds, [1]);
lifecycle.setLifecycle(AppLifecycleState.resumed);
await tester.pump();
await tester.pump();
expect(platform.createdCameraIds, [1]);
platform.failDispose(1);
await tester.pump();
await tester.pump();
expect(platform.createdCameraIds, [1, 2]);
final shutter = tester.widget<InkWell>(
find.byKey(const ValueKey('image-camera-shutter')),
);
expect(shutter.onTap, isNotNull);
await tester.pumpWidget(const SizedBox());
await tester.pump();
});
testWidgets('waits for an in-flight camera before lifecycle replacement', (
tester,
) async {
final platform = _TestCameraPlatform(
blockDisposeForCameraIds: {1},
blockInitializeForCameraIds: {1},
);
final previousPlatform = CameraPlatform.instance;
CameraPlatform.instance = platform;
addTearDown(() => CameraPlatform.instance = previousPlatform);
final lifecycle = _TestLifecycleNotifier();
await tester.pumpWidget(
ProviderScope(
overrides: [appLifecycleProvider.overrideWith(() => lifecycle)],
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: SizedBox(
height: 400,
child: ImageAvatarCapture(
height: 400,
onAccepted: (_) {},
onClosed: () {},
loadCameras: platform.availableCameras,
),
),
),
),
),
);
await tester.pump();
await tester.pump();
expect(platform.createdCameraIds, [1]);
lifecycle.setLifecycle(AppLifecycleState.paused);
await tester.pump();
lifecycle.setLifecycle(AppLifecycleState.resumed);
await tester.pump();
await tester.pump();
expect(platform.createdCameraIds, [1]);
platform.completeInitialize(1);
await tester.pump();
await tester.pump();
expect(platform.disposedCameraIds, [1]);
expect(platform.createdCameraIds, [1]);
platform.completeDispose(1);
await tester.pump();
await tester.pump();
expect(platform.createdCameraIds, [1, 2]);
final shutter = tester.widget<InkWell>(
find.byKey(const ValueKey('image-camera-shutter')),
);
expect(shutter.onTap, isNotNull);
await tester.pumpWidget(const SizedBox());
await tester.pump();
});
}
class _TestLifecycleNotifier extends AppLifecycleNotifier {
AppLifecycleState _lifecycle = AppLifecycleState.resumed;
@override
AppLifecycleState build() => _lifecycle;
void setLifecycle(AppLifecycleState value) {
_lifecycle = value;
state = value;
}
}
class _TestCameraPlatform extends CameraPlatform {
_TestCameraPlatform({
Set<int>? blockDisposeForCameraIds,
Set<int>? blockInitializeForCameraIds,
}) : _blockDisposeForCameraIds = blockDisposeForCameraIds ?? const {},
_blockInitializeForCameraIds = blockInitializeForCameraIds ?? const {};
final Set<int> _blockDisposeForCameraIds;
final Set<int> _blockInitializeForCameraIds;
final _disposeCompleters = <int, Completer<void>>{};
final _initializeCompleters = <int, Completer<void>>{};
final _initializedControllers =
<int, StreamController<CameraInitializedEvent>>{};
final _errorControllers = <int, StreamController<CameraErrorEvent>>{};
final _orientationController =
StreamController<DeviceOrientationChangedEvent>.broadcast();
final createdCameraIds = <int>[];
final disposedCameraIds = <int>[];
var _nextCameraId = 1;
@override
Future<List<CameraDescription>> availableCameras() async => const [
CameraDescription(
name: 'front',
lensDirection: CameraLensDirection.front,
sensorOrientation: 0,
),
];
@override
Future<int> createCameraWithSettings(
CameraDescription description,
MediaSettings mediaSettings,
) async {
final cameraId = _nextCameraId++;
createdCameraIds.add(cameraId);
_initializedControllers[cameraId] =
StreamController<CameraInitializedEvent>.broadcast();
_errorControllers[cameraId] =
StreamController<CameraErrorEvent>.broadcast();
return cameraId;
}
@override
Stream<CameraInitializedEvent> onCameraInitialized(int cameraId) =>
_initializedControllers[cameraId]!.stream.asBroadcastStream();
@override
Stream<CameraErrorEvent> onCameraError(int cameraId) =>
_errorControllers[cameraId]!.stream.asBroadcastStream();
@override
Stream<DeviceOrientationChangedEvent> onDeviceOrientationChanged() =>
_orientationController.stream;
@override
Future<void> initializeCamera(
int cameraId, {
ImageFormatGroup imageFormatGroup = ImageFormatGroup.unknown,
}) async {
if (_blockInitializeForCameraIds.contains(cameraId)) {
await (_initializeCompleters[cameraId] ??= Completer<void>()).future;
}
_initializedControllers[cameraId]!.add(
CameraInitializedEvent(
cameraId,
640,
480,
ExposureMode.auto,
false,
FocusMode.auto,
false,
),
);
}
@override
Future<void> lockCaptureOrientation(
int cameraId,
DeviceOrientation orientation,
) async {}
@override
Widget buildPreview(int cameraId) => const SizedBox.expand();
@override
Future<void> dispose(int cameraId) async {
disposedCameraIds.add(cameraId);
if (_blockDisposeForCameraIds.contains(cameraId)) {
await (_disposeCompleters[cameraId] ??= Completer<void>()).future;
}
}
void completeInitialize(int cameraId) {
_initializeCompleters[cameraId]!.complete();
}
void failDispose(int cameraId) {
_disposeCompleters[cameraId]!.completeError(
PlatformException(code: 'dispose-failed'),
);
}
void completeDispose(int cameraId) {
_disposeCompleters[cameraId]!.complete();
}
}

View File

@@ -28,25 +28,6 @@ void main() {
expect(service.uploadedParts, ['poster', 'animation', 'animation']);
});
test('animated draft supports one upload at a time', () async {
final service = _SingleUploadService();
addTearDown(service.dispose);
final draft = ProfileAnimatedAvatarDraft(
poster: Uint8List.fromList([1]),
animation: Uint8List.fromList([2]),
);
final url = await draft.upload(service);
expect(service.maxInFlight, 1);
expect(service.uploadedParts, ['poster', 'animation']);
expect(parseAnimatedAvatarUrl(url)?.posterUrl, 'https://relay/poster.png');
expect(
parseAnimatedAvatarUrl(url)?.animationUrl,
'https://relay/animation.png',
);
});
test('animated draft reuploads every part for a new community', () async {
final first = _RecordingUploadService('first');
final second = _RecordingUploadService('second');
@@ -67,46 +48,6 @@ void main() {
});
}
final class _SingleUploadService extends MediaUploadService {
_SingleUploadService()
: super(
baseUrl: 'https://relay.example',
nsec: null,
pickGalleryImage: () async => null,
pickGalleryVideo: () async => null,
);
final uploadedParts = <String>[];
int _inFlight = 0;
int maxInFlight = 0;
@override
Future<BlobDescriptor> uploadBytes(
Uint8List bytes, {
required String mimeType,
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
_inFlight++;
maxInFlight = _inFlight > maxInFlight ? _inFlight : maxInFlight;
if (_inFlight > 1) {
_inFlight--;
throw Exception('upload concurrency limit reached');
}
final part = bytes.single == 1 ? 'poster' : 'animation';
uploadedParts.add(part);
await Future<void>.delayed(Duration.zero);
_inFlight--;
return BlobDescriptor(
url: 'https://relay/$part.png',
sha256: '$part-hash',
size: bytes.length,
type: mimeType,
uploaded: 1,
);
}
}
final class _RecordingUploadService extends MediaUploadService {
_RecordingUploadService(this.community)
: super(

View File

@@ -2,10 +2,13 @@ import 'dart:async';
import 'dart:math' as math;
import 'package:buzz/features/profile/profile_edit_page.dart';
import 'package:buzz/features/profile/profile_avatar_draft.dart';
import 'package:buzz/features/profile/profile_avatar_crop_page.dart';
import 'package:buzz/features/profile/avatar_background_grid.dart';
import 'package:buzz/features/profile/avatar_editor_option_button.dart';
import 'package:buzz/features/profile/animated_avatar_capture.dart';
import 'package:buzz/features/profile/emoji_avatar_tile.dart';
import 'package:buzz/features/profile/image_avatar_capture.dart';
import 'package:buzz/shared/widgets/immediate_page_route.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/shared/emoji/emoji_avatar.dart';
@@ -18,6 +21,7 @@ import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/ios_native_segmented_control.dart';
import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart';
import 'package:buzz/shared/widgets/playing_avatar_image.dart';
import 'package:buzz/shared/widgets/progressive_animated_avatar.dart';
import 'package:flutter/foundation.dart';
@@ -540,13 +544,19 @@ void main() {
await tester.pump(const Duration(milliseconds: 150));
expect(
tester.getCenter(find.byKey(const ValueKey('emoji-avatar-preview'))).dy,
closeTo(screenSize.height / 2 - avatarBackgroundPreviewShift, 0.01),
closeTo(screenSize.height / 2 - 140, 0.01),
);
expect(
tester
.getSize(find.byKey(const ValueKey('emoji-avatar-picker-content')))
.height,
lessThan(expandedPickerHeight),
expandedPickerHeight,
);
expect(
tester.getCenter(find.byKey(const ValueKey('emoji-background-editor'))),
tester.getCenter(
find.byKey(const ValueKey('emoji-background-editor-alignment')),
),
);
await tester.tap(find.text('Animated'));
@@ -632,7 +642,14 @@ void main() {
profileProvider.overrideWith(() => notifier),
mediaUploadServiceProvider.overrideWithValue(uploadService),
],
child: const ProfileEditPage(),
child: ProfileEditPage(
imageAvatarCaptureBuilder:
({required height, required onAccepted, required onClosed}) =>
_FakeImageAvatarCapture(
onAccepted: onAccepted,
onClosed: onClosed,
),
),
),
);
await tester.pumpAndSettle();
@@ -640,18 +657,67 @@ void main() {
await tester.tap(find.text('Edit Photo'));
await tester.pumpAndSettle();
await tester.tap(find.text('Camera'));
await _waitForAvatarCropToLoad(tester);
expect(find.text('Position Photo'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo')));
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 200)),
);
await tester.pump();
expect(find.byKey(const ValueKey('fake-image-camera')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('fake-image-camera-accept')));
await tester.pumpAndSettle();
expect(notifier.savedAvatarUrls, isEmpty);
await tester.tap(find.byKey(const ValueKey('avatar-save')));
await tester.pumpAndSettle();
expect(notifier.savedAvatarUrls, ['https://relay.example/camera.png']);
expect(notifier.savedAvatarUrls, ['https://relay.example/profile.png']);
});
testWidgets('keeps the local animation visible after profile Save', (
tester,
) async {
final notifier = _FakeProfileNotifier(updatesProfileState: true);
final uploadService = _FakeMediaUploadService();
addTearDown(uploadService.dispose);
final frame = Uint8List.fromList(
image.encodePng(
image.Image(width: 8, height: 8)..setPixelRgba(4, 4, 255, 0, 0, 255),
),
);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(() => notifier),
mediaUploadServiceProvider.overrideWithValue(uploadService),
],
child: ProfileEditPage(
animatedAvatarCaptureBuilder:
({required height, required onPrepareChanged}) {
WidgetsBinding.instance.addPostFrameCallback((_) {
onPrepareChanged(
() async => ProfileAnimatedAvatarDraft(
animation: frame,
poster: frame,
),
);
});
return const SizedBox(
key: ValueKey('fake-animated-avatar-review'),
);
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Edit Photo'));
await tester.pumpAndSettle();
await tester.tap(find.text('Animated'));
await tester.pump();
await tester.pump();
await tester.tap(find.byKey(const ValueKey('avatar-save')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('progressive-animated-avatar-local-handoff')),
findsOneWidget,
);
expect(notifier.savedAvatarUrls.single, contains('#buzz-anim='));
});
testWidgets('saves a desktop-compatible emoji avatar', (tester) async {
@@ -847,10 +913,12 @@ class _FakeProfileNotifier extends ProfileNotifier {
about: 'Building Buzz',
),
this.failedAvatarSaves = 0,
this.updatesProfileState = false,
});
final UserProfile profile;
int failedAvatarSaves;
final bool updatesProfileState;
final savedDisplayNames = <String>[];
final savedDescriptions = <String>[];
final savedAvatarUrls = <String>[];
@@ -895,27 +963,35 @@ class _FakeProfileNotifier extends ProfileNotifier {
throw Exception('profile publish failed');
}
savedAvatarUrls.add(avatarUrl);
}
}
class _FailingPreparationMediaUploadService extends _FakeMediaUploadService {
@override
Future<Uint8List> prepareImageBytes(XFile image) async {
throw Exception('image preparation failed');
if (updatesProfileState) {
final current = state.requireValue!;
state = AsyncData(
UserProfile(
pubkey: current.pubkey,
displayName: current.displayName,
avatarUrl: avatarUrl,
about: current.about,
nip05Handle: current.nip05Handle,
),
);
}
}
}
class _FakeMediaUploadService extends MediaUploadService {
_FakeMediaUploadService({this.delayGallery = false})
: super(
baseUrl: 'https://relay.example',
nsec: null,
pickGalleryImage: () async => null,
pickGalleryVideo: () async => null,
);
_FakeMediaUploadService({
this.delayGallery = false,
this.failImagePreparation = false,
}) : super(
baseUrl: 'https://relay.example',
nsec: null,
pickGalleryImage: () async => null,
pickGalleryVideo: () async => null,
);
var _camera = false;
final bool delayGallery;
final bool failImagePreparation;
final _gallerySelection = Completer<XFile?>();
int uploadCount = 0;
@@ -941,7 +1017,10 @@ class _FakeMediaUploadService extends MediaUploadService {
}
@override
Future<Uint8List> prepareImageBytes(XFile image) => image.readAsBytes();
Future<Uint8List> prepareImageBytes(XFile image) async {
if (failImagePreparation) throw Exception('image preparation failed');
return image.readAsBytes();
}
@override
Future<BlobDescriptor> uploadBytes(
@@ -962,3 +1041,32 @@ class _FakeMediaUploadService extends MediaUploadService {
);
}
}
class _FakeImageAvatarCapture extends StatelessWidget {
const _FakeImageAvatarCapture({
required this.onAccepted,
required this.onClosed,
});
final ValueChanged<Uint8List> onAccepted;
final VoidCallback onClosed;
@override
Widget build(BuildContext context) => Row(
key: const ValueKey('fake-image-camera'),
children: [
TextButton(
key: const ValueKey('fake-image-camera-close'),
onPressed: onClosed,
child: const Text('Close fake camera'),
),
TextButton(
key: const ValueKey('fake-image-camera-accept'),
onPressed: () => onAccepted(
Uint8List.fromList(image.encodeJpg(image.Image(width: 8, height: 8))),
),
child: const Text('Accept fake photo'),
),
],
);
}

View File

@@ -1,6 +1,474 @@
part of '../profile_edit_page_test.dart';
double _paintedWidth(WidgetTester tester, String key) {
final box = tester.renderObject<RenderBox>(find.byKey(ValueKey(key)));
final left = box.localToGlobal(Offset.zero);
final right = box.localToGlobal(Offset(box.size.width, 0));
return (right - left).distance;
}
void runProfileEditImageSelectionTests() {
testWidgets('uses glass image source controls on iOS', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)],
child: const ProfileEditPage(startInPhotoEditor: true),
),
);
await tester.pumpAndSettle();
final nativeControls = tester
.widgetList<UiKitView>(find.byType(UiKitView))
.where((view) => view.viewType == IosGlassNavigationButton.viewType)
.map((view) => view.creationParams as Map<String, Object>)
.toList();
expect(nativeControls.any((params) => params['icon'] == 'camera'), isTrue);
expect(
nativeControls.any((params) => params['icon'] == 'photoLibrary'),
isTrue,
);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('opens the inline camera around the existing avatar center', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)],
child: const ProfileEditPage(startInPhotoEditor: true),
),
);
await tester.pumpAndSettle();
final avatarCenter = tester.getCenter(
find.byKey(const ValueKey('avatar-editor-fixed-preview')),
);
await tester.tap(find.byKey(const ValueKey('image-source-camera')));
await tester.pump();
expect(
tester.getCenter(find.byKey(const ValueKey('image-camera-preview-size'))),
avatarCenter,
);
});
testWidgets('expands the avatar with the controls while camera loads', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(
child: Scaffold(
body: SizedBox(
height: 400,
child: ImageAvatarCapture(
height: 400,
onAccepted: (_) {},
initialPreview: const SizedBox.square(
dimension: 220,
child: ColoredBox(
key: ValueKey('existing-avatar-preview'),
color: Colors.pink,
),
),
onClosed: () {},
loadCameras: () async => const [],
),
),
),
),
);
final preview = find.byKey(const ValueKey('image-camera-preview-size'));
expect(tester.getSize(preview), const Size.square(220));
expect(tester.getCenter(preview).dy, imageAvatarCameraPreviewSize / 2);
await tester.pump();
await tester.pump(const Duration(milliseconds: 90));
final midSize = tester.getSize(preview).width;
expect(midSize, greaterThan(220));
expect(midSize, lessThan(275));
expect(
_paintedWidth(tester, 'existing-avatar-preview'),
closeTo(midSize, 1),
);
expect(tester.getCenter(preview).dy, imageAvatarCameraPreviewSize / 2);
await tester.pump(const Duration(milliseconds: 90));
expect(tester.getSize(preview), const Size.square(275));
expect(_paintedWidth(tester, 'existing-avatar-preview'), closeTo(275, 1));
expect(
find.byKey(const ValueKey('existing-avatar-preview')),
findsOneWidget,
);
expect(
tester.getSize(find.byKey(const ValueKey('image-camera-left-action'))),
const Size.square(64),
);
expect(
tester.getSize(find.byKey(const ValueKey('image-camera-right-action'))),
const Size.square(64),
);
expect(
tester.getSize(find.byKey(const ValueKey('image-camera-shutter-morph'))),
const Size.square(100),
);
expect(find.bySemanticsLabel('Close camera'), findsOneWidget);
expect(find.bySemanticsLabel('Flip camera'), findsOneWidget);
expect(find.bySemanticsLabel('Take photo'), findsOneWidget);
});
testWidgets('reverses the camera controls before closing', (tester) async {
var closed = false;
await tester.pumpWidget(
WidgetHelpers.testable(
child: Scaffold(
body: SizedBox(
height: 400,
child: ImageAvatarCapture(
height: 400,
onAccepted: (_) {},
onClosed: () => closed = true,
loadCameras: () async => const [],
),
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 180));
final leftAction = find.byKey(const ValueKey('image-camera-left-action'));
final rightAction = find.byKey(const ValueKey('image-camera-right-action'));
final expandedDistance =
tester.getCenter(rightAction).dx - tester.getCenter(leftAction).dx;
tester
.widget<InkWell>(
find.descendant(of: leftAction, matching: find.byType(InkWell)),
)
.onTap!();
await tester.pump();
await tester.pump(const Duration(milliseconds: 90));
final midDistance =
tester.getCenter(rightAction).dx - tester.getCenter(leftAction).dx;
expect(midDistance, lessThan(expandedDistance));
expect(
find.byKey(const ValueKey('camera-action-icon-camera')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('camera-action-icon-photoLibrary')),
findsOneWidget,
);
expect(find.text('Camera'), findsOneWidget);
expect(find.text('Photo Library'), findsOneWidget);
final cameraIcon = tester.widget<Icon>(
find.byKey(const ValueKey('camera-action-icon-camera')),
);
expect(cameraIcon.color?.a, 1);
expect(closed, isFalse);
await tester.pump(const Duration(milliseconds: 60));
expect(
tester
.widget<Opacity>(
find.byKey(const ValueKey('image-camera-shutter-exit-opacity')),
)
.opacity,
0,
);
expect(closed, isFalse);
await tester.pump(const Duration(milliseconds: 30));
expect(closed, isTrue);
expect(
tester.getSize(find.byKey(const ValueKey('image-camera-preview-size'))),
const Size.square(220),
);
});
test('front camera output keeps the mirrored preview orientation', () {
final source = image.Image(width: 2, height: 2);
for (var y = 0; y < 2; y++) {
source.setPixelRgb(0, y, 255, 0, 0);
source.setPixelRgb(1, y, 0, 0, 255);
}
final encoded = Uint8List.fromList(image.encodePng(source));
final regular = image.decodeJpg(
prepareCameraImageForTesting(encoded, mirror: false),
)!;
final mirrored = image.decodeJpg(
prepareCameraImageForTesting(encoded, mirror: true),
)!;
final regularLeft = regular.getPixel(40, 256);
final mirroredLeft = mirrored.getPixel(40, 256);
expect(regularLeft.r, greaterThan(regularLeft.b));
expect(mirroredLeft.b, greaterThan(mirroredLeft.r));
});
testWidgets('provides haptics when closing or accepting a camera photo', (
tester,
) async {
final haptics = <Object?>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'HapticFeedback.vibrate') {
haptics.add(call.arguments);
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
await tester.pumpWidget(
WidgetHelpers.testable(
child: Scaffold(
body: ImageAvatarCapture(
key: const ValueKey('close-haptic-camera'),
height: 400,
onAccepted: (_) {},
onClosed: () {},
loadCameras: () async => const [],
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 180));
final closeAction = find.byKey(const ValueKey('image-camera-left-action'));
tester
.widget<InkWell>(
find.descendant(of: closeAction, matching: find.byType(InkWell)),
)
.onTap!();
await tester.pump();
expect(haptics, contains('HapticFeedbackType.selectionClick'));
await tester.pump(const Duration(milliseconds: 180));
final bytes = Uint8List.fromList(
image.encodeJpg(image.Image(width: 8, height: 8)),
);
await tester.pumpWidget(
WidgetHelpers.testable(
child: Scaffold(
body: ImageAvatarCapture(
key: const ValueKey('accept-haptic-camera'),
height: 400,
initialCapturedBytes: bytes,
onAccepted: (_) {},
onClosed: () {},
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Use Photo'));
await tester.pump();
expect(haptics, contains('HapticFeedbackType.mediumImpact'));
await tester.pump(const Duration(milliseconds: 180));
});
testWidgets('reviews a captured photo before scaling down to accept it', (
tester,
) async {
final bytes = Uint8List.fromList(
image.encodeJpg(image.Image(width: 8, height: 8)),
);
Uint8List? accepted;
await tester.pumpWidget(
WidgetHelpers.testable(
child: Scaffold(
body: SizedBox(
height: 400,
child: ImageAvatarCapture(
height: 400,
initialCapturedBytes: bytes,
onAccepted: (value) => accepted = value,
onClosed: () {},
),
),
),
),
);
final preview = find.byKey(const ValueKey('image-camera-preview-size'));
final compactCenter = tester.getCenter(preview);
expect(tester.getSize(preview), const Size.square(220));
await tester.pumpAndSettle();
expect(tester.getSize(preview), const Size.square(275));
expect(tester.getCenter(preview), compactCenter);
expect(find.text('Retry'), findsOneWidget);
expect(find.text('Use Photo'), findsOneWidget);
expect(
tester.getSize(find.byKey(const ValueKey('image-camera-left-action'))),
const Size(112, 64),
);
expect(
tester.getSize(find.byKey(const ValueKey('image-camera-right-action'))),
const Size(112, 64),
);
final leftRect = tester.getRect(
find.byKey(const ValueKey('image-camera-left-action')),
);
final rightRect = tester.getRect(
find.byKey(const ValueKey('image-camera-right-action')),
);
expect(rightRect.left - leftRect.right, 12);
await tester.tap(find.text('Use Photo'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 90));
final midSize = tester
.getSize(find.byKey(const ValueKey('image-camera-preview-size')))
.width;
expect(midSize, greaterThan(220));
expect(midSize, lessThan(275));
expect(accepted, isNull);
await tester.pump(const Duration(milliseconds: 90));
expect(accepted, same(bytes));
expect(
tester.getSize(find.byKey(const ValueKey('image-camera-preview-size'))),
const Size.square(220),
);
});
testWidgets('retry moves the review actions apart again', (tester) async {
final bytes = Uint8List.fromList(
image.encodeJpg(image.Image(width: 8, height: 8)),
);
await tester.pumpWidget(
WidgetHelpers.testable(
child: Scaffold(
body: SizedBox(
height: 400,
child: ImageAvatarCapture(
height: 400,
initialCapturedBytes: bytes,
onAccepted: (_) {},
onClosed: () {},
loadCameras: () async => const [],
),
),
),
),
);
await tester.pumpAndSettle();
final leftAction = find.byKey(const ValueKey('image-camera-left-action'));
final rightAction = find.byKey(const ValueKey('image-camera-right-action'));
final reviewGap =
tester.getRect(rightAction).left - tester.getRect(leftAction).right;
await tester.tap(find.text('Retry'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 180));
final cameraGap =
tester.getRect(rightAction).left - tester.getRect(leftAction).right;
expect(reviewGap, 12);
expect(cameraGap, greaterThan(reviewGap));
expect(find.bySemanticsLabel('Close camera'), findsOneWidget);
expect(find.bySemanticsLabel('Flip camera'), findsOneWidget);
});
testWidgets('clears gallery errors when opening the inline camera', (
tester,
) async {
final uploadService = _FakeMediaUploadService(failImagePreparation: true);
addTearDown(uploadService.dispose);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(_FakeProfileNotifier.new),
mediaUploadServiceProvider.overrideWithValue(uploadService),
],
child: ProfileEditPage(
imageAvatarCaptureBuilder:
({required height, required onAccepted, required onClosed}) =>
_FakeImageAvatarCapture(
onAccepted: onAccepted,
onClosed: onClosed,
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Edit Photo'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('image-source-library')));
await tester.pumpAndSettle();
expect(
find.text("We couldn't prepare that photo. Try again."),
findsOneWidget,
);
await tester.tap(find.byKey(const ValueKey('image-source-camera')));
await tester.pump();
expect(find.byKey(const ValueKey('fake-image-camera')), findsOneWidget);
expect(
find.text("We couldn't prepare that photo. Try again."),
findsNothing,
);
await tester.tap(find.byKey(const ValueKey('fake-image-camera-accept')));
await tester.pumpAndSettle();
expect(
find.text("We couldn't prepare that photo. Try again."),
findsNothing,
);
});
testWidgets('accepts an inline camera photo before enabling profile Save', (
tester,
) async {
final notifier = _FakeProfileNotifier();
final uploadService = _FakeMediaUploadService();
addTearDown(uploadService.dispose);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(() => notifier),
mediaUploadServiceProvider.overrideWithValue(uploadService),
],
child: ProfileEditPage(
imageAvatarCaptureBuilder:
({required height, required onAccepted, required onClosed}) =>
_FakeImageAvatarCapture(
onAccepted: onAccepted,
onClosed: onClosed,
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Edit Photo'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('image-source-camera')));
await tester.pump();
expect(find.byKey(const ValueKey('fake-image-camera')), findsOneWidget);
expect(find.byKey(const ValueKey('image-source-library')), findsNothing);
await tester.tap(find.byKey(const ValueKey('fake-image-camera-accept')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('fake-image-camera')), findsNothing);
expect(find.byKey(const ValueKey('image-source-camera')), findsOneWidget);
expect(find.byKey(const ValueKey('image-source-library')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('avatar-save')));
await tester.pumpAndSettle();
expect(notifier.savedAvatarUrls, ['https://relay.example/profile.png']);
expect(uploadService.uploadCount, 1);
});
testWidgets('duplicate avatar Back taps pop only the editor route', (
tester,
) async {

View File

@@ -53,97 +53,6 @@ void runProfileEditMotionAndAccessibilityTests() {
);
});
testWidgets(
'keeps the multiline text editor usable with large text and the keyboard',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(320, 568);
tester.platformDispatcher.textScaleFactorTestValue = 2;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
addTearDown(tester.view.reset);
final notifier = _FakeProfileNotifier();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [profileProvider.overrideWith(() => notifier)],
child: const ProfileEditPage(),
),
);
await tester.pumpAndSettle();
final descriptionRow = find.byKey(
const ValueKey('profile-description-row'),
);
await tester.ensureVisible(descriptionRow);
await tester.pumpAndSettle();
await tester.tap(descriptionRow);
await tester.pumpAndSettle();
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pumpAndSettle();
expect(
MediaQuery.textScalerOf(
tester.element(find.byKey(const ValueKey('profile-field-input'))),
).scale(10),
20,
);
expect(
MediaQuery.viewInsetsOf(
tester.element(find.byKey(const ValueKey('profile-field-input'))),
).bottom,
300,
);
expect(tester.takeException(), isNull);
expect(
find.byKey(const ValueKey('profile-field-scroll-view')),
findsOneWidget,
);
await tester.enterText(
find.byKey(const ValueKey('profile-field-input')),
'Making collaboration feel effortless.',
);
await tester.pump();
final save = find.byKey(const ValueKey('profile-field-save'));
await tester.ensureVisible(save);
await tester.pumpAndSettle();
expect(save.hitTestable(), findsOneWidget);
await tester.tap(save);
await tester.pumpAndSettle();
expect(notifier.savedDescriptions, [
'Making collaboration feel effortless.',
]);
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets('photo preparation errors are accessibility live regions', (
tester,
) async {
final uploadService = _FailingPreparationMediaUploadService();
addTearDown(uploadService.dispose);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(_FakeProfileNotifier.new),
mediaUploadServiceProvider.overrideWithValue(uploadService),
],
child: const ProfileEditPage(startInPhotoEditor: true),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Photo Library'));
await tester.pumpAndSettle();
final message = find.text("We couldn't prepare that photo. Try again.");
expect(message, findsOneWidget);
final errorSemantics = tester.widget<Semantics>(
find.ancestor(of: message, matching: find.byType(Semantics)).first,
);
expect(errorSemantics.properties.liveRegion, isTrue);
});
testWidgets('exposes the selected avatar mode on Android', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
@@ -472,6 +381,135 @@ void runProfileEditMotionAndAccessibilityTests() {
expect(selectedTile.properties.selected, isTrue);
});
testWidgets('uses liquid-glass avatar rail icons on iOS', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await tester.pumpWidget(
WidgetHelpers.testable(
child: Row(
children: [
Expanded(
child: AvatarEditorOptionButton(
icon: Icons.palette,
iosIcon: IosGlassNavigationIcon.palette,
label: 'Background',
selected: true,
onTap: () {},
),
),
Expanded(
child: AvatarEditorOptionButton(
icon: Icons.face,
iosIcon: IosGlassNavigationIcon.emoji,
label: 'Emoji',
selected: false,
onTap: () {},
),
),
],
),
),
);
final nativeIcons = tester
.widgetList<UiKitView>(find.byType(UiKitView))
.map((view) => view.creationParams as Map<String, Object>)
.map((params) => params['icon'])
.toList();
expect(nativeIcons, containsAll(['palette', 'emoji']));
final selectedControls = tester
.widgetList<UiKitView>(find.byType(UiKitView))
.map((view) => view.creationParams as Map<String, Object>)
.where((params) => params['selected'] == true)
.toList();
expect(selectedControls.single['icon'], 'palette');
expect(
selectedControls.single['foregroundColor'],
AppTheme.light().colorScheme.primary.toARGB32(),
);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('doubles the avatar rail icon-to-label spacing', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
child: AvatarEditorOptionButton(
icon: Icons.palette,
label: 'Background',
selected: false,
onTap: () {},
),
),
);
final surface = find.byType(AnimatedContainer);
expect(
tester.getRect(find.text('Background')).top -
tester.getRect(surface).bottom,
avatarEditorOptionLabelGap,
);
});
testWidgets('uses liquid glass for animated capture and review on iOS', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await tester.pumpWidget(
WidgetHelpers.testable(
child: SizedBox(
height: 500,
child: AnimatedAvatarCapture(
key: ValueKey('animated-capture-glass-test'),
height: 500,
onPrepareChanged: (_) {},
),
),
),
);
await tester.pump();
var nativeControls = tester
.widgetList<UiKitView>(find.byType(UiKitView))
.map((view) => view.creationParams as Map<String, Object>)
.toList();
expect(
nativeControls.any(
(params) => params['icon'] == 'shutter' && params['label'] == 'Record',
),
isTrue,
);
final frame = Uint8List.fromList(
image.encodePng(image.Image(width: 8, height: 8)),
);
await tester.pumpWidget(
WidgetHelpers.testable(
child: SizedBox(
height: 500,
child: AnimatedAvatarCapture(
key: ValueKey('animated-review-glass-test'),
height: 500,
initialFrames: [frame, frame],
onPrepareChanged: (_) {},
),
),
),
);
await tester.pump();
nativeControls = tester
.widgetList<UiKitView>(find.byType(UiKitView))
.map((view) => view.creationParams as Map<String, Object>)
.toList();
final reviewIcons = nativeControls
.map((params) => params['icon'])
.whereType<String>()
.toList();
expect(reviewIcons, containsAll(['person', 'palette', 'frame', 'camera']));
debugDefaultTargetPlatformOverride = null;
});
testWidgets('uses the shared animated background grid for emoji avatars', (
tester,
) async {

View File

@@ -274,127 +274,6 @@ void main() {
debugDefaultTargetPlatformOverride = null;
});
testWidgets('standalone Flutter text draft survives Back while saving', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final notifier = _DeferredFailureProfileNotifier();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [profileProvider.overrideWith(() => notifier)],
child: Builder(
builder: (context) => TextButton(
onPressed: () => unawaited(showProfileDisplayNameEditor(context)),
child: const Text('Open editor'),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open editor'));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('profile-field-input')),
'Pending standalone draft',
);
await tester.pump();
await tester.tap(find.byKey(const ValueKey('profile-field-save')));
await tester.pump();
await tester.binding.handlePopRoute();
await tester.pump();
expect(find.byKey(const ValueKey('profile-field-input')), findsOneWidget);
expect(
tester
.widget<IconButton>(find.byKey(const ValueKey('profile-field-close')))
.onPressed,
isNull,
);
notifier.failSave();
await tester.pumpAndSettle();
expect(
find.text("We couldn't save this change. Try again."),
findsOneWidget,
);
expect(
tester
.widget<Semantics>(
find
.ancestor(
of: find.text("We couldn't save this change. Try again."),
matching: find.byType(Semantics),
)
.first,
)
.properties
.liveRegion,
isTrue,
);
expect(tester.takeException(), isNull);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('in-page Flutter text draft survives Back while saving', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final notifier = _DeferredFailureProfileNotifier();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [profileProvider.overrideWith(() => notifier)],
child: const ProfileEditPage(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('profile-display-name-row')));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('profile-field-input')),
'Pending in-page draft',
);
await tester.pump();
await tester.tap(find.byKey(const ValueKey('profile-field-save')));
await tester.pump();
await tester.binding.handlePopRoute();
await tester.pump();
expect(find.byKey(const ValueKey('profile-field-input')), findsOneWidget);
expect(
tester
.widget<IconButton>(find.byKey(const ValueKey('profile-field-close')))
.onPressed,
isNull,
);
notifier.failSave();
await tester.pumpAndSettle();
expect(
find.text("We couldn't save this change. Try again."),
findsOneWidget,
);
expect(
tester
.widget<Semantics>(
find
.ancestor(
of: find.text("We couldn't save this change. Try again."),
matching: find.byType(Semantics),
)
.first,
)
.properties
.liveRegion,
isTrue,
);
expect(tester.takeException(), isNull);
debugDefaultTargetPlatformOverride = null;
});
testWidgets('in-page text editor rejects its first save after a switch', (
tester,
) async {

View File

@@ -13,6 +13,7 @@ import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -124,7 +125,6 @@ void main() {
.map((image) => image.url),
containsAll([posterUrl, animationUrl]),
);
animationResponse.complete(http.Response.bytes(_transparentPng, 200));
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
@@ -168,6 +168,78 @@ void main() {
);
});
testWidgets('warms and clears a paused animated-avatar handoff', (
tester,
) async {
const posterUrl = 'https://relay.example/media/poster.png';
const animationUrl = 'https://relay.example/media/animation.png';
final profileUrl =
'$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}';
final posterResponse = Completer<http.Response>();
final animationResponse = Completer<http.Response>();
final client = http_testing.MockClient(
(request) => switch (request.url.toString()) {
posterUrl => posterResponse.future,
animationUrl => animationResponse.future,
_ => Future.value(http.Response.bytes(_transparentPng, 200)),
},
);
addTearDown(client.close);
final container = ProviderContainer(
overrides: [
profileProvider.overrideWith(
() => _FakeProfileNotifier(avatarUrl: profileUrl),
),
presenceProvider.overrideWith(() => _FakePresenceNotifier('online')),
userStatusProvider.overrideWith(() => _FakeUserStatusNotifier(null)),
customEmojiListProvider.overrideWithValue(const []),
mediaGetAuthServiceProvider.overrideWithValue(
MediaGetAuthService(baseUrl: 'https://relay.example', nsec: null),
),
mediaHttpClientProvider.overrideWithValue(client),
],
);
addTearDown(container.dispose);
container
.read(profileAvatarHandoffProvider.notifier)
.show(
ProfileAvatarHandoff(
avatarUrl: profileUrl,
animation: _transparentPng,
poster: _transparentPng,
),
);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
theme: AppTheme.light(),
home: const Scaffold(body: SettingsProfileHeader()),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('settings-profile-avatar')));
await tester.pump();
expect(
find.byKey(
ValueKey('settings-profile-paused-handoff-$profileUrl'),
skipOffstage: false,
),
findsOneWidget,
);
expect(container.read(profileAvatarHandoffProvider), isNotNull);
posterResponse.complete(http.Response.bytes(_transparentPng, 200));
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pumpAndSettle();
expect(container.read(profileAvatarHandoffProvider), isNull);
});
testWidgets('uses a bounded icon for an unresolved status shortcode', (
tester,
) async {