Files
buzz/mobile/lib/features/profile/profile_edit_page.dart
klopez4212 6cf4582002 Use native iOS navigation bars with large titles and avatars (#8052)
## Summary

Use UIKit navigation bars across iOS pages, with large titles that
collapse into the toolbar on scroll and a light native blur. Home uses
the active community name as its title and shows community and profile
avatars with centered emoji and stable loading, without flashing back to
icons. Channel headers retain the member count, tap-to-settings title,
and Huddle action. Their ultra-thin native material has a soft lower
edge and stays visible at the timeline bottom. The hidden title-layout
scroll view has its automatic edge effects disabled so it cannot add a
second backdrop.

| Expanded Home | Scrolled Home | Settings |
| --- | --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--home.png"
alt="home" width="240"> | <img
src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--scrolled.png"
alt="scrolled" width="240"> | <img
src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--settings.png"
alt="settings" width="240"> |

Channel title, member count, and Huddle control:

<img
src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--channel.png"
alt="Channel navigation" width="280">

### Related issue

No matching open issue found.

### Testing

- Added causal regressions for Profile/Edit Photo native titles,
metrics-only collapse changes, and native-header suppression throughout
channel/thread reaction and action backdrops. Each regression fails when
its corresponding fix is removed.

- Mobile formatting and analysis passed. Full mobile test rerun passed:
2,622 tests, 4 skipped, plus 3 push-disabled build tests.
- Native iPhone Air regression passed: repeated title expansion/collapse
and scroll material behavior.
- Navigation and blur were accepted on the connected iPhone using the
existing profile. Simulator snapshots use real community data. The
subsequent review fixes have widget coverage; fresh physical-device
verification of Profile states, Dynamic Type changes, and message
backdrops is pending.
- Full repository `just ci` was blocked by local disk exhaustion while
compiling Rust dependencies.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-03 18:10:03 +01:00

720 lines
27 KiB
Dart

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 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/profile/user_profile.dart';
import '../../shared/relay/relay.dart';
import '../../shared/emoji/emoji_data_provider.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/app_list.dart';
import '../../shared/widgets/app_list_card.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/ios_glass_navigation_action.dart';
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';
/// Edits the current user's public profile metadata.
class ProfileEditPage extends HookConsumerWidget {
/// Creates the profile details and avatar editing page.
const ProfileEditPage({
super.key,
this.startInPhotoEditor = false,
this.animatedAvatarCaptureBuilder,
this.imageAvatarCaptureBuilder,
});
/// Opens directly into the photo editor when launched from Settings.
final bool startInPhotoEditor;
/// 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
Widget build(BuildContext context, WidgetRef ref) {
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final profileAsync = ref.watch(profileProvider);
final profile = profileAsync.asData?.value;
final profileHydrated = profileAsync.hasValue;
final isEditingAvatar = useState(startInPhotoEditor);
final avatarDraft = useState<ProfileAvatarDraft?>(null);
final avatarDraftMode = useState<ProfileAvatarMode?>(null);
final avatarEditConfig = useRef<RelayConfig?>(
startInPhotoEditor ? ref.read(relayConfigProvider) : null,
);
final isSavingAvatar = useState(false);
final isClosingAvatar = useState(false);
final prepareAnimatedAvatar =
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
? Duration.zero
: const Duration(milliseconds: 200),
);
useEffect(() {
if (!startInPhotoEditor) return null;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) unawaited(avatarTransition.forward(from: 0));
});
return null;
}, const []);
// Warm the emoji index while the profile is visible so the first editor
// transition never waits for asset decoding.
ref.watch(emojiDatasetProvider);
Future<void> showFlutterFieldEditor({
required String title,
required String initialValue,
required String hintText,
required Future<void> Function(String value) onSave,
bool multiline = false,
}) => showBuzzModalBottomSheet<void>(
context: context,
title: title,
isScrollControlled: true,
requestFocus: true,
builder: (_) => _ProfileTextEditSheet(
initialValue: initialValue,
hintText: hintText,
multiline: multiline,
onSave: onSave,
),
);
Future<void> editField({
required String title,
required String initialValue,
required String hintText,
required Future<void> Function(String value) onSave,
bool multiline = false,
}) async {
if (defaultTargetPlatform == TargetPlatform.iOS) {
final sheetTheme = utilitySurfaceThemeData(Theme.of(context));
try {
await IosProfileTextEditor.presentUntilSaved(
title: title,
initialValue: initialValue,
placeholder: hintText,
multiline: multiline,
brightness: sheetTheme.brightness,
pageBackgroundColor: sheetTheme.colorScheme.surface,
containerBackgroundColor:
sheetTheme.colorScheme.surfaceContainerHighest,
onSave: onSave,
shouldRetryOnError: (error) =>
error is! ProfileCommunityChangedException,
canPresent: () =>
context.mounted && (ModalRoute.of(context)?.isCurrent ?? true),
onSaveError: () {
if (!context.mounted ||
!(ModalRoute.of(context)?.isCurrent ?? true)) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("We couldn't save this change. Try again."),
),
);
},
);
return;
} on MissingPluginException {
// Keep the Flutter editor available in previews and older builds.
} on PlatformException {
// Fall back when the native presenter is temporarily unavailable.
}
}
await showFlutterFieldEditor(
title: title,
initialValue: initialValue,
hintText: hintText,
multiline: multiline,
onSave: onSave,
);
}
void openAvatarEditor() {
if (!profileHydrated) return;
avatarEditConfig.value = ref.read(relayConfigProvider);
isEditingAvatar.value = true;
unawaited(avatarTransition.forward(from: 0));
}
Future<void> closeAvatarEditor({bool whileSaving = false}) async {
if ((isSavingAvatar.value && !whileSaving) || isClosingAvatar.value) {
return;
}
isClosingAvatar.value = true;
await avatarTransition.reverse();
if (!context.mounted) return;
if (startInPhotoEditor) {
Navigator.of(context).pop();
return;
}
isEditingAvatar.value = false;
avatarDraft.value = null;
avatarDraftMode.value = null;
avatarEditConfig.value = null;
prepareAnimatedAvatar.value = null;
canPrepareAnimatedAvatar.value = false;
isImageCameraActive.value = false;
avatarMode.value = ProfileAvatarMode.image;
isClosingAvatar.value = false;
}
Future<void> saveAvatar() async {
if (isSavingAvatar.value || isClosingAvatar.value) return;
final openingConfig = avatarEditConfig.value;
if (openingConfig == null) return;
final saveConfig = ref.read(relayConfigProvider);
final uploadService = ref.read(mediaUploadServiceProvider);
void requireCurrentCommunity() {
final currentConfig = ref.read(relayConfigProvider);
if (currentConfig.storedOrigin != openingConfig.storedOrigin ||
currentConfig.nsec != openingConfig.nsec ||
currentConfig.storedOrigin != saveConfig.storedOrigin ||
currentConfig.nsec != saveConfig.nsec ||
!identical(ref.read(mediaUploadServiceProvider), uploadService)) {
throw ProfileCommunityChangedException();
}
}
Future<void> discardStaleEditor() async {
avatarDraft.value = null;
avatarDraftMode.value = null;
prepareAnimatedAvatar.value = null;
canPrepareAnimatedAvatar.value = false;
if (context.mounted) await closeAvatarEditor(whileSaving: true);
}
isSavingAvatar.value = true;
avatarSaveError.value = null;
try {
requireCurrentCommunity();
var nextDraft = avatarDraftMode.value == avatarMode.value
? avatarDraft.value
: null;
if (avatarMode.value == ProfileAvatarMode.animated &&
nextDraft == null) {
nextDraft = await prepareAnimatedAvatar.value?.call();
requireCurrentCommunity();
if (nextDraft != null) {
avatarDraft.value = nextDraft;
avatarDraftMode.value = ProfileAvatarMode.animated;
}
}
if (nextDraft == null) return;
requireCurrentCommunity();
final nextAvatar = await nextDraft.upload(uploadService);
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();
} catch (_) {
try {
requireCurrentCommunity();
} on ProfileCommunityChangedException {
await discardStaleEditor();
return;
}
avatarSaveError.value =
"We couldn't save your profile photo. Try again.";
} finally {
if (context.mounted) isSavingAvatar.value = false;
}
}
final canSaveAvatar =
profileHydrated &&
!isImageCameraActive.value &&
(avatarMode.value == ProfileAvatarMode.animated
? canPrepareAnimatedAvatar.value
: avatarDraftMode.value == avatarMode.value &&
avatarDraft.value != null);
final activeDraft = avatarDraftMode.value == avatarMode.value
? avatarDraft.value
: null;
final avatarHandoff = ref.watch(profileAvatarHandoffProvider);
final pageTitle = isEditingAvatar.value ? 'Edit Photo' : 'Profile';
return PopScope(
canPop: !isEditingAvatar.value,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && isEditingAvatar.value) {
unawaited(closeAvatarEditor());
}
},
child: FrostedScaffold(
useUtilitySurfaceTheme: true,
resizeToAvoidBottomInset: isEditingAvatar.value ? false : null,
appBar: FrostedAppBar(
nativeTitle: pageTitle,
centerTitle: true,
title: AnimatedSwitcher(
duration: reduceMotion
? const Duration(milliseconds: 120)
: const Duration(milliseconds: 220),
child: Text(pageTitle, key: ValueKey(isEditingAvatar.value)),
),
leading: isEditingAvatar.value
? defaultTargetPlatform == TargetPlatform.iOS
? IosGlassNavigationButton(
key: const ValueKey('avatar-editor-back'),
icon: IosGlassNavigationIcon.back,
semanticLabel: 'Back to profile',
onPressed: isClosingAvatar.value
? null
: () => unawaited(closeAvatarEditor()),
)
: IconButton(
key: const ValueKey('avatar-editor-back'),
tooltip: 'Back to profile',
onPressed: isClosingAvatar.value
? null
: () => unawaited(closeAvatarEditor()),
icon: const Icon(LucideIcons.arrowLeft),
)
: null,
actions: isEditingAvatar.value
? [
if (defaultTargetPlatform == TargetPlatform.iOS)
IosGlassNavigationAction(
key: const ValueKey('avatar-save'),
label: 'Save',
width: 72,
isBusy: isSavingAvatar.value,
onPressed:
canSaveAvatar &&
!isSavingAvatar.value &&
!isClosingAvatar.value
? () {
unawaited(HapticFeedback.lightImpact());
unawaited(saveAvatar());
}
: null,
)
else
Padding(
padding: const EdgeInsets.only(right: Grid.xs),
child: _ProfileActionPill(
key: const ValueKey('avatar-save'),
label: 'Save',
isBusy: isSavingAvatar.value,
onTap:
canSaveAvatar &&
!isSavingAvatar.value &&
!isClosingAvatar.value
? () {
unawaited(HapticFeedback.lightImpact());
unawaited(saveAvatar());
}
: null,
),
),
]
: const [],
),
body: isEditingAvatar.value
? Stack(
children: [
IgnorePointer(
ignoring: !profileHydrated || isSavingAvatar.value,
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
key: const ValueKey('avatar-editor-scroll-view'),
physics: constraints.maxHeight < 600
? const ClampingScrollPhysics()
: const NeverScrollableScrollPhysics(),
child: SizedBox(
height: constraints.maxHeight < 600
? 700
: constraints.maxHeight,
child: ProfileAvatarEditor(
key: const ValueKey('profile-avatar-editor-page'),
currentAvatarUrl: profile?.avatarUrl,
fallbackInitial: profile?.initial ?? '?',
draft: activeDraft,
mode: avatarMode.value,
transition: avatarTransition,
onModeChanged: (nextMode) =>
avatarMode.value = nextMode,
onDraftChanged: (draft) {
avatarDraft.value = draft;
avatarDraftMode.value = draft == null
? null
: avatarMode.value;
},
onAnimatedPrepareChanged: (prepare) {
prepareAnimatedAvatar.value = prepare;
canPrepareAnimatedAvatar.value = prepare != null;
if (avatarMode.value ==
ProfileAvatarMode.animated) {
avatarDraft.value = null;
avatarDraftMode.value = null;
}
},
onImageCameraActiveChanged: (active) {
isImageCameraActive.value = active;
},
animatedCaptureBuilder:
animatedAvatarCaptureBuilder,
imageCaptureBuilder: imageAvatarCaptureBuilder,
),
),
),
),
),
if (!profileHydrated || avatarSaveError.value != null)
Positioned(
left: Grid.gutter,
right: Grid.gutter,
bottom: Grid.xl,
child: Semantics(
liveRegion: true,
child: Text(
avatarSaveError.value ??
(profileAsync.hasError
? "We couldn't load your profile. Go back and try again."
: 'Loading your profile…'),
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color:
avatarSaveError.value != null ||
profileAsync.hasError
? context.colors.error
: context.colors.onSurfaceVariant,
),
),
),
),
],
)
: ListView(
key: const ValueKey('profile-information-page'),
padding: EdgeInsets.only(
top: frostedAppBarHeight(context) + Grid.sm,
bottom: Grid.sm,
),
children: [
_ProfilePhotoEditor(
profile: profile,
handoff: avatarHandoff,
onEditPhoto: profileHydrated ? openAvatarEditor : null,
),
AppListCard(
key: const ValueKey('profile-info-card'),
dividerIndent: Grid.xs,
verticalPadding: Grid.sm,
children: [
AppListRow(
key: const ValueKey('profile-display-name-row'),
title: 'Display name',
subtitle: _fieldValue(profile?.displayName),
subtitleMaxLines: 1,
trailing: const _EditChevron(),
onTap: !profileHydrated
? null
: () {
final container = ProviderScope.containerOf(
context,
listen: false,
);
unawaited(
editField(
title: 'Display name',
initialValue: profile?.displayName ?? '',
hintText: 'Display name',
onSave: bindProfileSaveToOpeningContext(
container,
container
.read(profileProvider.notifier)
.updateDisplayName,
),
),
);
},
),
AppListRow(
key: const ValueKey('profile-description-row'),
title: 'Profile description',
subtitle: _fieldValue(profile?.about),
subtitleMaxLines: 3,
trailing: const _EditChevron(),
onTap: !profileHydrated
? null
: () {
final container = ProviderScope.containerOf(
context,
listen: false,
);
unawaited(
editField(
title: 'Profile description',
initialValue: profile?.about ?? '',
hintText: 'Profile description',
multiline: true,
onSave: bindProfileSaveToOpeningContext(
container,
container
.read(profileProvider.notifier)
.updateAbout,
),
),
);
},
),
],
),
],
),
),
);
}
}
String _fieldValue(String? value) {
final trimmed = value?.trim() ?? '';
return trimmed.isEmpty ? 'Not set' : trimmed;
}
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, 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,
),
],
);
}
}
class _ProfileActionPill extends StatelessWidget {
const _ProfileActionPill({
super.key,
required this.label,
required this.onTap,
this.semanticLabel,
this.isBusy = false,
});
final String label;
final String? semanticLabel;
final VoidCallback? onTap;
final bool isBusy;
@override
Widget build(BuildContext context) => Semantics(
button: true,
enabled: onTap != null,
label: semanticLabel ?? label,
child: Material(
color: onTap == null
? context.colors.surfaceContainerHighest.withValues(alpha: 0.5)
: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.full),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.xxs,
),
child: isBusy
? const BuzzLoadingIndicator(
size: 18,
semanticLabel: 'Saving profile photo',
)
: Text(
label,
style: context.textTheme.labelLarge?.copyWith(
color: onTap == null
? context.colors.onSurfaceVariant
: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
),
),
);
}
class _EditChevron extends StatelessWidget {
const _EditChevron();
@override
Widget build(BuildContext context) => Icon(
LucideIcons.chevronRight,
size: 18,
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'),
),
],
),
),
);
}
}