Files
buzz/mobile/lib/features/profile/profile_edit_page.dart
klopez4212 c8ea4fb4eb feat(mobile): polish Home navigation and Solo workspace (#8393)
## Summary
Polish mobile Home and Solo: group Search/Inbox beside a separate
profile avatar, move Inbox and Settings to bottom-up pages with Close
controls, and place tabs opposite the plus/compose action. Morph plus to
compose clockwise when entering Solo and reverse counterclockwise when
returning Home, with a directional quarter-turn of the icon inside a
level button and a subtle scale pulse, respecting reduced motion. Align
Search’s Close control with Inbox, simplify its placeholder, and
initialize selected filter contrast correctly. Collapse the plus menu
when leaving Home. Center Solo’s native-glass New chat empty state and
keep it stationary behind the keyboard.

Standardize Tabler icons and neutral backgrounds, retain SF Back/Close
on iOS, use native Liquid Glass headers, tab container, and actions
(solid grey selected tab, slightly darker on iOS) on iOS and black
actions on Android, and smooth the expanded menu corners. Repair native
header safe-area/return layout and compose movement after cancellation.

### Related issue
None found for this UI refinement; follows #8337.

### Testing
- Formatting, analysis, full mobile suite: 3,487 passed, 4 skipped; 3
push-configuration tests passed. File-size checks passed.
- Base UI built and installed on Pixel and iPhone in place. Pixel
navigation/New chat cancellation checked. Subsequent glass and rotation
refinements are previewed on iPhone; motion feel awaits human
confirmation.
- Draft pending human confirmation of the final Settings entrance/return
behavior on iPhone.

### Pixel snapshots
| Solo | Expanded actions |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-solo.png"
width="280" alt="Solo empty state with New chat" /> | <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-actions.png"
width="280" alt="Expanded channel actions with smooth corners" /> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-16 12:28:02 -04: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:buzz/shared/theme/buzz_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(BuzzIcons.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.controlSurface,
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(
BuzzIcons.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'),
),
],
),
),
);
}
}