Files
buzz/mobile/lib/features/profile/profile_setup_avatar_editor.dart
klopez4212 5c2ea2e507 feat(mobile): add full-screen profile setup with inline avatar editing (#8628)
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>
2026-10-27 22:47:35 +00:00

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: (_) {},
),
),
),
),
),
),
],
),
),
);
}
}