Files
buzz/mobile/lib/features/profile/settings_profile_header.dart
b30f1f6129 Polish mobile profiles, DMs, and sheets (#5401)
## Summary
- add poster-first, tap-to-toggle animated avatars on profile surfaces
while preserving transparent/static behavior elsewhere
- align mobile DM headers, membership actions, and invisible agent
recipient addressing with established desktop semantics
- polish titled sheets and status editing, preserve native iOS sheet
corners, and batch relay reads to improve review-build responsiveness

## Snapshots

<table>
  <tr>
    <th>Profile avatar</th>
    <th>Agent DM header and composer</th>
  </tr>
  <tr>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--profile-avatar.png"
width="360" alt="Mobile profile settings with animated avatar
surface"></td>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--agent-dm.png"
width="360" alt="Agent direct message with masked presence and normal
composer"></td>
  </tr>
  <tr>
    <th>Members sheet</th>
    <th>Status editor</th>
  </tr>
  <tr>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--members-sheet.png"
width="360" alt="Members bottom sheet with centered title and padded
content"></td>
<td><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--status-sheet.png"
width="360" alt="Status editor bottom sheet with duration and quick
statuses"></td>
  </tr>
  <tr>
    <th colspan="2">Switch Community</th>
  </tr>
  <tr>
<td colspan="2" align="center"><img
src="https://raw.githubusercontent.com/block/buzz/e8de7495451dbe1a393ab43c5e204ca7425f2ba5/pr-5401--switch-community.png"
width="720" alt="Switch Community bottom sheet with centered title and
aligned Edit action"></td>
  </tr>
</table>

## Validation
- `just mobile-check`
- `just mobile-test` (1,283 tests)
- installed and reviewed isolated debug builds on iPhone and Pixel

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-13 20:16:04 -07:00

345 lines
12 KiB
Dart

import 'dart:async';
import 'package:flutter/material.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/animated_avatar.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/anchored_popover_menu.dart';
import '../../shared/widgets/masked_avatar_badge.dart';
import '../../shared/widgets/progressive_animated_avatar.dart';
import 'profile_provider.dart';
import 'set_status_sheet.dart';
import 'user_status_provider.dart';
/// Desktop's settings-avatar treatment (`ProfileSettingsCard`): a large centred
/// avatar with a circular badge notched out of its bottom-right corner. Desktop
/// puts an edit-photo pencil in that badge; here it carries the status glyph and
/// opens the status sheet instead. The notch shape — including the fillets where
/// it meets the avatar's edge — comes from [AvatarBadgeMaskGeometry.badge].
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 presence = ref.watch(presenceProvider).value ?? 'offline';
final animatedAvatar = parseAnimatedAvatarUrl(profile?.avatarUrl);
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(
padding: const EdgeInsets.only(top: Grid.sm, bottom: Grid.sm),
child: Column(
children: [
MaskedAvatarBadge(
size: _avatarSize,
avatar: 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.colors.primaryContainer
: Colors.transparent,
child:
animatedAvatar != null &&
stoppedAnimationUrl.value != animatedAvatar.animationUrl
? ProgressiveAnimatedAvatar(
key: ValueKey(animatedAvatar.animationUrl),
descriptor: animatedAvatar,
fallback: _AvatarFallback(initial: profile?.initial),
)
: AvatarImageContent(
imageUrl: avatarUrl,
fallback: _AvatarFallback(initial: profile?.initial),
),
),
),
badge: _StatusBadge(
emoji: status?.emoji ?? '',
onTap: openStatusSheet,
),
),
const SizedBox(height: Grid.twelve),
Text(
profile?.label ?? 'Your profile',
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
),
// Keep the status text visible even when no emoji is set. NIP-38
// permits text-only statuses, which the avatar badge cannot represent.
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(
status.text.isNotEmpty ? status.text : status.emoji,
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 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);
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 {
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(
LucideIcons.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,
};
/// Fills the notch left by [MaskedAvatarBadge], so its size comes from the mask
/// geometry rather than being set here.
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.emoji, required this.onTap});
final String emoji;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: 'Set a status',
child: GestureDetector(
onTap: onTap,
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Center(child: _StatusGlyph(emoji: emoji)),
),
),
);
}
}
/// The status emoji, resolving `:shortcode:` values against the community's
/// custom emoji. Falls back to the add-status icon when no status is set.
class _StatusGlyph extends ConsumerWidget {
const _StatusGlyph({required this.emoji});
final String emoji;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (emoji.isEmpty) {
return Icon(
LucideIcons.smilePlus,
size: 20,
color: context.colors.onSurfaceVariant,
);
}
final shortcode = emoji.startsWith(':') && emoji.endsWith(':')
? emoji.substring(1, emoji.length - 1).toLowerCase()
: null;
if (shortcode != null) {
for (final entry in ref.watch(customEmojiListProvider)) {
if (entry.shortcode == shortcode) {
return CustomEmojiImage(
shortcode: shortcode,
url: entry.url,
size: 22,
);
}
}
return Icon(
LucideIcons.smile,
size: 20,
color: context.colors.onSurfaceVariant,
);
}
return Text(emoji, style: const TextStyle(fontSize: 20));
}
}