mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
Replace the iOS profile setup sheet with a full-screen name → avatar flow. Require a display name, enable avatar Continue after Done, and let Skip save the name without an avatar. After a failed save, Not now continues without saving the draft. Embed photo, emoji, and animated avatar editing with native Back/Done, matching 220-point previews, a rounded-notch pencil edit badge, an entry haptic, equal emoji-preview spacing, and 200 ms transitions that move the avatar into its editor position without resizing. Apply appearance changes live across setup and editing while preserving the draft. Keep the camera exit rail stationary, use a solid shutter, and present photo positioning full-screen with Back/Done. Preserve the main Flutter view during presentation and hide underlying controls from VoiceOver while editing. Validation: Flutter analysis, 3,796 Flutter tests, and 106 native tests passed. Built, installed, and launched the isolated iPhone preview; inspected the native emoji/badge rendering. The local preview entry point is excluded. Draft pending real login/join → profile-save verification and owner verification of the updated device flow. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
293 lines
10 KiB
Dart
293 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../../shared/widgets/ios_glass_navigation_button.dart';
|
|
import '../../shared/widgets/frosted_app_bar.dart';
|
|
import '../../shared/theme/theme.dart';
|
|
import '../../shared/widgets/frosted_scaffold.dart';
|
|
import '../../shared/widgets/ios_glass_navigation_action.dart';
|
|
import 'profile_setup_empty_avatar.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import 'profile_avatar_draft.dart';
|
|
import 'profile_avatar_editor.dart';
|
|
|
|
/// Reuses the profile editor while returning a local draft instead of publishing.
|
|
class ProfileSetupAvatarEditor extends HookConsumerWidget {
|
|
const ProfileSetupAvatarEditor({
|
|
super.key,
|
|
this.initialDraft,
|
|
this.onFinished,
|
|
this.onPreviewReady,
|
|
this.anchoredPreview,
|
|
});
|
|
final ProfileAvatarDraft? initialDraft;
|
|
final Rect? anchoredPreview;
|
|
final ValueChanged<ProfileAvatarDraft?>? onFinished;
|
|
final Future<void> Function(Rect)? onPreviewReady;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final previewBounds = useState(anchoredPreview);
|
|
final previewVisible = useState(
|
|
onPreviewReady == null || anchoredPreview != null,
|
|
);
|
|
final previewKey = useMemoized(() => GlobalKey());
|
|
useEffect(() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
final box = previewKey.currentContext?.findRenderObject();
|
|
if (box is RenderBox && box.hasSize) {
|
|
try {
|
|
await onPreviewReady
|
|
?.call(box.localToGlobal(Offset.zero) & box.size)
|
|
.timeout(const Duration(seconds: 2), onTimeout: () {});
|
|
} finally {
|
|
if (context.mounted) previewVisible.value = true;
|
|
}
|
|
}
|
|
});
|
|
return null;
|
|
}, const []);
|
|
final draft = useState(initialDraft);
|
|
final mode = useState(switch (initialDraft) {
|
|
ProfileUrlAvatarDraft() => ProfileAvatarMode.emoji,
|
|
ProfileAnimatedAvatarDraft() => ProfileAvatarMode.animated,
|
|
_ => ProfileAvatarMode.image,
|
|
});
|
|
final prepare = useState<Future<ProfileAvatarDraft?> Function()?>(null);
|
|
final busy = useState(false);
|
|
final error = useState(false);
|
|
final transition = useAnimationController(
|
|
initialValue: anchoredPreview == null ? 1 : 0,
|
|
duration: const Duration(milliseconds: 200),
|
|
);
|
|
final VoidCallback? close = busy.value
|
|
? null
|
|
: () {
|
|
if (onFinished != null) {
|
|
onFinished!(null);
|
|
} else {
|
|
Navigator.of(context).pop();
|
|
}
|
|
};
|
|
final routeIsCurrent = ModalRoute.isCurrentOf(context) ?? true;
|
|
final VoidCallback? done =
|
|
busy.value ||
|
|
!routeIsCurrent ||
|
|
(draft.value == null && prepare.value == null)
|
|
? null
|
|
: () async {
|
|
busy.value = true;
|
|
error.value = false;
|
|
try {
|
|
final result =
|
|
mode.value == ProfileAvatarMode.animated &&
|
|
prepare.value != null
|
|
? await prepare.value!()
|
|
: draft.value;
|
|
if (context.mounted && result != null) {
|
|
if (onFinished != null) {
|
|
onFinished!(result);
|
|
} else {
|
|
Navigator.of(context).pop(result);
|
|
}
|
|
}
|
|
} catch (_) {
|
|
if (context.mounted) error.value = true;
|
|
} finally {
|
|
if (context.mounted) busy.value = false;
|
|
}
|
|
};
|
|
final doneRef = useRef(done)..value = done;
|
|
final closeRef = useRef(close)..value = close;
|
|
useEffect(() {
|
|
if (anchoredPreview == null) return null;
|
|
const channel = MethodChannel('buzz/profile_avatar_sheet');
|
|
channel.setMethodCallHandler((call) async {
|
|
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
|
if (call.method == 'done') doneRef.value?.call();
|
|
if (call.method == 'back') {
|
|
final navigator = Navigator.of(context);
|
|
if (navigator.canPop()) {
|
|
navigator.pop();
|
|
} else {
|
|
closeRef.value?.call();
|
|
}
|
|
}
|
|
if (call.method == 'hide' || call.method == 'reveal') {
|
|
final bounds = call.arguments;
|
|
if (bounds is Map) {
|
|
previewBounds.value = Rect.fromLTWH(
|
|
(bounds['x'] as num).toDouble(),
|
|
(bounds['y'] as num).toDouble(),
|
|
(bounds['width'] as num).toDouble(),
|
|
(bounds['height'] as num).toDouble(),
|
|
);
|
|
}
|
|
}
|
|
if (call.method == 'hide') {
|
|
if (reduceMotion) {
|
|
transition.value = 0;
|
|
} else {
|
|
await transition.reverse().orCancel;
|
|
}
|
|
}
|
|
if (call.method == 'hide') {
|
|
// Let UIKit replace this view only after the final avatar is painted.
|
|
await WidgetsBinding.instance.endOfFrame;
|
|
}
|
|
if (call.method == 'reveal') {
|
|
if (reduceMotion) {
|
|
transition.value = 1;
|
|
} else {
|
|
transition.forward(from: 0);
|
|
}
|
|
}
|
|
});
|
|
return () => channel.setMethodCallHandler(null);
|
|
}, const []);
|
|
useEffect(() {
|
|
if (anchoredPreview == null) return null;
|
|
const channel = MethodChannel('buzz/profile_avatar_sheet');
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (context.mounted) {
|
|
channel.invokeMethod<void>('controls', {
|
|
'canDone': done != null,
|
|
'busy': busy.value,
|
|
'fullScreen': !routeIsCurrent,
|
|
});
|
|
}
|
|
});
|
|
return null;
|
|
}, [done != null, busy.value, routeIsCurrent]);
|
|
if (anchoredPreview != null) {
|
|
return Theme(
|
|
data: utilitySurfaceThemeData(Theme.of(context)),
|
|
child: Scaffold(
|
|
backgroundColor: utilitySurfaceThemeData(
|
|
Theme.of(context),
|
|
).colorScheme.surface,
|
|
resizeToAvoidBottomInset: false,
|
|
body: Column(
|
|
children: [
|
|
if (error.value)
|
|
const Text('Could not prepare this photo. Try again.'),
|
|
Expanded(
|
|
child: IgnorePointer(
|
|
ignoring: busy.value,
|
|
child: ProfileAvatarEditor(
|
|
anchoredPreview: previewBounds.value,
|
|
previewKey: previewKey,
|
|
previewOpacity: previewVisible.value ? 1 : 0,
|
|
currentAvatarUrl: initialDraft is ProfileUrlAvatarDraft
|
|
? (initialDraft as ProfileUrlAvatarDraft).url
|
|
: null,
|
|
fallbackInitial: '+',
|
|
emptyPreview: const ProfileSetupEmptyAvatar(),
|
|
draft: draft.value,
|
|
mode: mode.value,
|
|
transition: transition,
|
|
onModeChanged: (value) {
|
|
mode.value = value;
|
|
draft.value = null;
|
|
prepare.value = null;
|
|
},
|
|
onDraftChanged: (value) => draft.value = value,
|
|
onAnimatedPrepareChanged: (value) => prepare.value = value,
|
|
onImageCameraActiveChanged: (_) {},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return PopScope(
|
|
canPop: !busy.value,
|
|
child: FrostedScaffold(
|
|
useUtilitySurfaceTheme: true,
|
|
resizeToAvoidBottomInset: false,
|
|
appBar: FrostedAppBar(
|
|
nativeTitle: 'Profile photo',
|
|
centerTitle: true,
|
|
title: const Text('Profile photo'),
|
|
leading: defaultTargetPlatform == TargetPlatform.iOS
|
|
? IosGlassNavigationButton(
|
|
icon: IosGlassNavigationIcon.back,
|
|
semanticLabel: 'Back to profile',
|
|
onPressed: close,
|
|
)
|
|
: IconButton(
|
|
onPressed: close,
|
|
tooltip: 'Back to profile',
|
|
icon: const Icon(Icons.arrow_back),
|
|
),
|
|
actions: [
|
|
if (defaultTargetPlatform == TargetPlatform.iOS)
|
|
IosGlassNavigationAction(
|
|
label: 'Done',
|
|
width: 72,
|
|
isBusy: busy.value,
|
|
onPressed: done,
|
|
)
|
|
else
|
|
TextButton(
|
|
onPressed: done,
|
|
child: Text(busy.value ? 'Preparing…' : 'Done'),
|
|
),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
if (error.value)
|
|
const Text('Could not prepare this photo. Try again.'),
|
|
Expanded(
|
|
child: IgnorePointer(
|
|
ignoring: busy.value,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) => SingleChildScrollView(
|
|
physics: constraints.maxHeight < 720
|
|
? const ClampingScrollPhysics()
|
|
: const NeverScrollableScrollPhysics(),
|
|
child: SizedBox(
|
|
height: constraints.maxHeight < 720
|
|
? 720
|
|
: constraints.maxHeight,
|
|
child: ProfileAvatarEditor(
|
|
previewKey: previewKey,
|
|
headerGap: Grid.lg,
|
|
previewOpacity: previewVisible.value ? 1 : 0,
|
|
currentAvatarUrl: initialDraft is ProfileUrlAvatarDraft
|
|
? (initialDraft as ProfileUrlAvatarDraft).url
|
|
: null,
|
|
fallbackInitial: '+',
|
|
emptyPreview: const ProfileSetupEmptyAvatar(),
|
|
draft: draft.value,
|
|
mode: mode.value,
|
|
transition: transition,
|
|
onModeChanged: (value) {
|
|
mode.value = value;
|
|
draft.value = null;
|
|
prepare.value = null;
|
|
},
|
|
onDraftChanged: (value) => draft.value = value,
|
|
onAnimatedPrepareChanged: (value) {
|
|
prepare.value = value;
|
|
},
|
|
onImageCameraActiveChanged: (_) {},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|