Files
buzz/mobile/lib/features/profile/profile_setup_avatar_editor.dart
klopez4212 f7708d0694 Polish mobile onboarding and profile setup (#8438)
Originally stacked on #8375, now merged. Rebased onto main.

Polishes the onboarding landing page, community-link entry, and recovery
sheets. Profile setup uses a consistent sheet with a larger avatar,
inline display name, and draft photo/emoji/animated editing; iOS uses
native controls and coordinated avatar/sheet transitions.

Validation: mobile analysis, full tests, and normal pre-push checks
passed after rebase. Prior iOS build and device walkthrough passed;
native validation was not repeated after this rebase. Snapshots omitted
as requested.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
2026-10-22 17:27:38 +00:00

176 lines
6.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/foundation.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,
});
final ProfileAvatarDraft? initialDraft;
final ValueChanged<ProfileAvatarDraft?>? onFinished;
final Future<void> Function(Rect)? onPreviewReady;
@override
Widget build(BuildContext context, WidgetRef ref) {
final previewVisible = useState(onPreviewReady == 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: 1);
final VoidCallback? close = busy.value
? null
: () {
if (onFinished != null) {
onFinished!(null);
} else {
Navigator.of(context).pop();
}
};
final VoidCallback? done =
busy.value || (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;
}
};
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: (_) {},
),
),
),
),
),
),
],
),
),
);
}
}