mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
71
mobile/lib/features/profile/camera_disposal_barrier.dart
Normal file
71
mobile/lib/features/profile/camera_disposal_barrier.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
662
mobile/lib/features/profile/image_avatar_capture.dart
Normal file
662
mobile/lib/features/profile/image_avatar_capture.dart
Normal 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));
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user