Files
buzz/mobile/lib/features/profile/settings_profile_header.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

370 lines
14 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.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/animated_avatar.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/relay/media_image.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/anchored_popover_menu.dart';
import '../../shared/widgets/progressive_animated_avatar.dart';
import 'profile_provider.dart';
import 'ios_presence_menu.dart';
import 'set_status_sheet.dart';
import 'user_status_provider.dart';
/// Profile avatar, display name, status, and presence shown above settings.
class SettingsProfileHeader extends HookConsumerWidget {
const SettingsProfileHeader({super.key});
static const _avatarSize = 128.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
final profile = ref.watch(profileProvider).asData?.value;
final status = ref.watch(userStatusProvider).asData?.value;
final hasStatus = status != null && !status.isEmpty;
final palette = ref.watch(customEmojiListProvider);
final shortcode = normalizeShortcode(status?.emoji ?? '');
final customEmoji = palette
.where((entry) => entry.shortcode == shortcode)
.firstOrNull;
final presence = ref.watch(presenceProvider).value ?? 'offline';
final animatedAvatar = parseAnimatedAvatarUrl(profile?.avatarUrl);
final animatedPosterUrl = animatedAvatar?.posterUrl;
final handoff = ref.watch(profileAvatarHandoffProvider);
final activeHandoff = handoff?.avatarUrl == profile?.avatarUrl
? handoff
: null;
final stoppedAnimationUrl = useState<String?>(null);
final avatarUrl = animatedAvatar == null
? profile?.avatarUrl
: stoppedAnimationUrl.value == animatedAvatar.animationUrl
? animatedAvatar.posterUrl
: null;
void openStatusSheet() =>
showSetStatusSheet(context, currentStatus: status);
return Padding(
key: const ValueKey('settings-profile-header'),
padding: const EdgeInsets.only(top: Grid.sm, bottom: Grid.twelve),
child: Column(
children: [
SizedBox.square(
dimension: _avatarSize,
child: ClipOval(
child: GestureDetector(
key: const ValueKey('settings-profile-avatar'),
onTap: animatedAvatar == null
? null
: () => stoppedAnimationUrl.value =
stoppedAnimationUrl.value ==
animatedAvatar.animationUrl
? null
: animatedAvatar.animationUrl,
child: ColoredBox(
key: const ValueKey('settings-profile-avatar-background'),
color: animatedAvatar == null
? context.controlSurface
: Colors.transparent,
child:
animatedAvatar != null &&
stoppedAnimationUrl.value !=
animatedAvatar.animationUrl
? ProgressiveAnimatedAvatar(
key: ValueKey(animatedAvatar.animationUrl),
descriptor: animatedAvatar,
fallback: _AvatarFallback(initial: profile?.initial),
loadingImage: activeHandoff == null
? null
: MemoryImage(activeHandoff.animation),
onAnimationReady: activeHandoff == null
? null
: () => ref
.read(profileAvatarHandoffProvider.notifier)
.clear(activeHandoff.avatarUrl),
)
: activeHandoff == null || animatedPosterUrl == null
? AvatarImageContent(
imageUrl: avatarUrl,
fallback: _AvatarFallback(initial: profile?.initial),
)
: Stack(
fit: StackFit.expand,
children: [
Image(
image: MemoryImage(activeHandoff.poster),
fit: BoxFit.cover,
gaplessPlayback: true,
),
Offstage(
offstage: true,
child: MediaImage(
key: ValueKey(
'settings-profile-paused-handoff-${activeHandoff.avatarUrl}',
),
url: animatedPosterUrl,
fit: BoxFit.cover,
errorBuilder: (_, _, _) =>
const SizedBox.shrink(),
frameBuilder:
(
context,
child,
frame,
wasSynchronouslyLoaded,
) {
if (wasSynchronouslyLoaded ||
frame != null) {
WidgetsBinding.instance
.addPostFrameCallback((_) {
ref
.read(
profileAvatarHandoffProvider
.notifier,
)
.clear(
activeHandoff.avatarUrl,
);
});
}
return child;
},
),
),
],
),
),
),
),
),
const SizedBox(height: Grid.twelve),
Text(
profile?.label ?? 'Your profile',
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
),
// Preserve the complete status below the name, including custom emoji.
if (hasStatus)
GestureDetector(
onTap: openStatusSheet,
child: Padding(
padding: const EdgeInsets.only(
top: Grid.quarter,
left: Grid.gutter,
right: Grid.gutter,
bottom: Grid.half,
),
child: Text.rich(
TextSpan(
children: [
if (customEmoji != null)
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: CustomEmojiImage(
shortcode: customEmoji.shortcode,
url: customEmoji.url,
),
)
else if (status.emoji.isNotEmpty)
TextSpan(text: status.emoji),
if (status.emoji.isNotEmpty && status.text.isNotEmpty)
const TextSpan(text: ' '),
if (status.text.isNotEmpty) TextSpan(text: status.text),
],
),
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
_PresencePill(
presence: presence,
onSelected: (nextPresence) => unawaited(
ref.read(presenceProvider.notifier).setPresence(nextPresence),
),
),
],
),
);
}
}
class _AvatarFallback extends StatelessWidget {
const _AvatarFallback({required this.initial});
final String? initial;
@override
Widget build(BuildContext context) {
return Center(
child: Text(
initial ?? '?',
style: context.textTheme.displaySmall?.copyWith(
color: context.colors.onPrimaryContainer,
),
),
);
}
}
class _PresencePill extends StatelessWidget {
const _PresencePill({required this.presence, required this.onSelected});
final String presence;
final ValueChanged<String> onSelected;
@override
Widget build(BuildContext context) {
final effectivePresence = switch (presence) {
'online' || 'away' => presence,
_ => 'offline',
};
final (backgroundColor, foregroundColor) = switch (effectivePresence) {
'online' => (
context.appColors.success.withValues(alpha: 0.15),
context.appColors.success,
),
'away' => (
context.appColors.warning.withValues(alpha: 0.15),
context.appColors.warning,
),
_ => (
context.colors.onSurfaceVariant.withValues(alpha: 0.15),
context.colors.onSurfaceVariant,
),
};
final label = _presenceLabel(effectivePresence);
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) {
return IosPresenceMenu(
presence: effectivePresence,
label: label,
foreground: foregroundColor,
background: backgroundColor,
onSelected: onSelected,
);
}
return Builder(
builder: (buttonContext) => Semantics(
button: true,
label: 'Presence: $label',
child: SizedBox(
key: const ValueKey('settings-presence-target'),
height: Grid.xl,
child: Material(
color: Colors.transparent,
child: InkWell(
key: const ValueKey('settings-presence-menu'),
borderRadius: BorderRadius.circular(Radii.full),
onTap: () async {
unawaited(HapticFeedback.selectionClick());
final selected = await showAnchoredPopover<String>(
context: buttonContext,
width: 176,
alignment: AnchoredPopoverAlignment.center,
offset: const Offset(0, Grid.half),
menuPadding: const EdgeInsets.symmetric(vertical: Grid.half),
surfaceKey: const ValueKey('settings-presence-popover'),
items: [
for (final option in const ['online', 'away', 'offline'])
PopupMenuItem<String>(
key: ValueKey('settings-presence-$option'),
value: option,
height: Grid.xl,
padding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
),
child: Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: _presenceColor(context, option),
shape: BoxShape.circle,
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
_presenceLabel(option),
style: filterChipTextStyle.copyWith(
color: context.colors.onSurface,
fontWeight: option == effectivePresence
? FontWeight.w500
: FontWeight.w400,
),
),
),
if (option == effectivePresence)
Icon(
BuzzIcons.check,
size: 16,
color: context.colors.primary,
),
],
),
),
],
);
if (buttonContext.mounted && selected != null) {
onSelected(selected);
}
},
child: Center(
child: Material(
key: const ValueKey('settings-presence-pill'),
color: backgroundColor,
borderRadius: BorderRadius.circular(Radii.full),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.xxs,
),
child: Text(
label,
key: const ValueKey('settings-presence-label'),
style: filterChipTextStyle.copyWith(
color: foregroundColor,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
),
),
),
);
}
}
String _presenceLabel(String presence) => switch (presence) {
'online' => 'Online',
'away' => 'Away',
_ => 'Offline',
};
Color _presenceColor(BuildContext context, String presence) =>
switch (presence) {
'online' => context.appColors.success,
'away' => context.appColors.warning,
_ => context.colors.outline,
};