Files
buzz/mobile/lib/features/profile/user_profile_sheet.dart
klopez4212 0bdad8ea96 mobile: simplify verified profiles and prioritize full names (#9051)
Simplifies mobile identity presentation: removes verification checkmarks
from messages, threads, mentions and profiles, plus
issuer/time/disclaimer copy. Profiles show the community-verified full
name as the title and the display name below, with fallback when no full
name exists.

Validation: mobile formatting/analysis, full Flutter suite (4,401
passed, 45 skipped), all 4 unconfigured-push checks, and pre-push hooks
passed. Independent review found no blockers. Device and human visual
checks pending.

<details>
<summary>Before / after snapshots</summary>

Real Flutter profile widget with synthetic data, light/dark themes;
widget previews, not device screenshots. Images are PR attachments only.

| Theme | Before | After |
| --- | --- | --- |
| Light | ![Before,
light](https://github.com/user-attachments/assets/d615c010-7f04-4480-ae5e-e11539abc860)
| ![After,
light](https://github.com/user-attachments/assets/5726e504-349c-4c74-afc5-5557b03061bb)
|
| Dark | ![Before,
dark](https://github.com/user-attachments/assets/29204e7c-840b-41b4-b916-784976e6cbc9)
| ![After,
dark](https://github.com/user-attachments/assets/6c399553-4d95-47e8-9a5d-a2d351598ab3)
|

</details>


<details>
<summary>Inline messages — before / after</summary>

Actual Flutter channel and thread widgets with synthetic verified-user
data; local widget previews, not device screenshots.

| Screen | Before | After |
| --- | --- | --- |
| Channel · light | ![Before: channel,
light](https://github.com/user-attachments/assets/e482b562-5b44-4e48-8a24-ea52e78a8936)
| ![After: channel,
light](https://github.com/user-attachments/assets/2dd3689c-3206-4bc0-84be-0828e0fcca84)
|
| Channel · dark | ![Before: channel,
dark](https://github.com/user-attachments/assets/3c848519-3b96-453f-8751-78c573211f13)
| ![After: channel,
dark](https://github.com/user-attachments/assets/cc781417-e7ef-4988-98b4-b562c075fff6)
|
| Thread · light | ![Before: thread,
light](https://github.com/user-attachments/assets/4117bd8b-de7e-45c7-8e5a-3b25ae600405)
| ![After: thread,
light](https://github.com/user-attachments/assets/b2f8bddb-3023-448b-88fa-a9bc1d3b2ec2)
|
| Thread · dark | ![Before: thread,
dark](https://github.com/user-attachments/assets/4b9fd1fc-0a53-41b0-804c-3f06cd7a2e03)
| ![After: thread,
dark](https://github.com/user-attachments/assets/b407c3c6-c67a-445f-8867-feff60ca5973)
|

</details>

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-04 16:00:52 +00:00

673 lines
26 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import '../../shared/widgets/profile_action_chip.dart';
import '../../shared/widgets/app_list_card.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart' show ProviderListenable;
import 'package:buzz/shared/theme/buzz_icons.dart';
import '../../shared/animated_avatar.dart';
import '../../shared/widgets/buzz_toast.dart';
import '../../shared/identity_names/identity_names.dart';
import '../../shared/identity_names/identity_names_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/utils/string_utils.dart';
import '../../shared/widgets/avatar_shape.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/agent_artwork_panel.dart';
import '../../shared/widgets/buzz_sheet_header.dart';
import '../../shared/verified_profiles/verified_profile_directory.dart';
import '../../shared/widgets/buzz_action_tile.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/progressive_animated_avatar.dart';
import '../channels/channel.dart';
import '../channels/channel_detail_page.dart';
import '../channels/channel_management_provider.dart';
import '../channels/message_content.dart';
import 'presence_cache_provider.dart';
import '../../shared/profile/user_cache_provider.dart';
import 'user_status_cache_provider.dart';
/// Show a user profile bottom sheet for the given [pubkey].
///
/// The sheet names [pubkey] as the opening surface did: within the live
/// comparison context [names] (for example a channel's members). Without it,
/// the identity is compared only with itself.
void showUserProfileSheet(
BuildContext context,
String pubkey, {
ProviderListenable<IdentityNames>? names,
WidgetBuilder? contextualActions,
}) {
showBuzzModalBottomSheet<Channel>(
context: context,
isScrollControlled: true,
showDragHandle: false,
builder: (_) => UserProfileSheet(
pubkey: pubkey,
names: names,
contextualActions: contextualActions,
),
).then((channel) {
if (channel == null || !context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(
channel: channel,
placement: ChannelRoutePlacement.ordinary,
),
),
);
});
}
class UserProfileSheet extends HookConsumerWidget {
final String pubkey;
/// Agent artwork treatment within the profile sheet.
final bool agentPresentation;
final String? agentSubtitle;
final Widget? agentSubtitleIcon;
/// The opening surface's comparison context. The sheet watches it, so its
/// title follows profile and context changes while it is open. The caller
/// owns the context, so this sheet depends on no other feature's state.
final ProviderListenable<IdentityNames>? names;
/// Optional actions supplied by the opening surface, below the profile tiles.
final WidgetBuilder? contextualActions;
/// Optional action opposite the sheet close control.
final Widget? headerLeading;
/// Agent-specific content supplied by the owning surface.
final WidgetBuilder? agentIdentity;
final WidgetBuilder? agentSettings;
/// Space before the primary profile actions.
final double primaryActionsSpacing;
/// Space between primary actions and caller-supplied actions.
final double contextualActionsSpacing;
const UserProfileSheet({
super.key,
required this.pubkey,
this.agentPresentation = false,
this.agentSubtitle,
this.agentSubtitleIcon,
this.names,
this.contextualActions,
this.headerLeading,
this.agentIdentity,
this.agentSettings,
this.primaryActionsSpacing = Grid.xs + Grid.half,
this.contextualActionsSpacing = Grid.xs,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pk = pubkey.toLowerCase();
final currentPubkey = ref.watch(currentPubkeyProvider);
// Watch cached profile, presence, and user status.
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
ref.read(userCacheProvider.notifier).get(pk);
final presenceMap = ref.watch(presentedPresenceProvider);
final presence = presenceMap[pk];
final statusCache = ref.watch(userStatusCacheProvider);
final userStatus = statusCache[pk];
// Fetch about from the user's kind:0 profile event.
final aboutFuture = useMemoized(
() => ref
.read(relaySessionProvider.notifier)
.fetchHistory(NostrFilters.profile(pk))
.then((events) {
if (events.isEmpty) return '';
return ProfileData.fromEvent(events.first).about ?? '';
})
.catchError((_) => ''),
[pk],
);
final aboutSnapshot = useFuture(aboutFuture);
final about = aboutSnapshot.data ?? profile?.about ?? '';
// Ensure presence and status are tracked.
useEffect(() {
ref.read(presenceCacheProvider.notifier).track([pk]);
ref.read(userStatusCacheProvider.notifier).track([pk]);
ref.read(userCacheProvider.notifier).preload([pk]);
return null;
}, [pk]);
final copied = useState(false);
final isOpeningDirectMessage = useState(false);
// Canonical npub for the copy action; null when [pubkey] is not a valid
// identity, in which case the copy tile is disabled — an invalid key is
// never placed on the clipboard.
final npub = fullNpub(pubkey);
// Keep the opening surface's comparison context and qualified identity.
final opener = names;
final displayName =
(opener != null ? ref.watch(opener) : watchIdentityNames(ref, {pk}))
.labelFor(pk);
final verifiedFullName = ref.watch(
verifiedProfilesProvider.select((all) => all[pk]?.fullName),
);
final titleName = agentPresentation
? displayName
: verifiedFullName ?? displayName;
final secondaryName = titleName != displayName ? displayName : null;
final avatarUrl = profile?.avatarUrl;
final nip05 = profile?.nip05Handle;
final initial =
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
final copyGeneration = useRef(0);
Future<void> copyPublicKey() async {
if (npub == null) return;
await Clipboard.setData(ClipboardData(text: npub));
if (!context.mounted) return;
final generation = ++copyGeneration.value;
copied.value = true;
showBuzzToast(context, 'Public key copied', kind: ToastKind.success);
unawaited(
Future<void>.delayed(const Duration(seconds: 2), () {
if (context.mounted && copyGeneration.value == generation) {
copied.value = false;
}
}),
);
}
Future<void> openDirectMessage() async {
if (isOpeningDirectMessage.value) return;
isOpeningDirectMessage.value = true;
try {
final channel = await ref
.read(channelActionsProvider)
.openDm(pubkeys: [pk], waitForChannelList: false);
if (!context.mounted) return;
Navigator.of(context).pop(channel);
} catch (_) {
// Silently fail — user tapped but DM open failed.
if (context.mounted) isOpeningDirectMessage.value = false;
}
}
final content = ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
child: Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
MediaQuery.viewInsetsOf(context).bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: Grid.xs),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Center the presence chip on the avatar's lower edge.
Padding(
padding: EdgeInsets.only(
bottom: agentPresentation ? 0 : Grid.xl / 2,
),
child: Stack(
clipBehavior: Clip.none,
children: [
Align(
alignment: Alignment.center,
child: agentPresentation
? SizedBox(
width: Grid.xxl * 2,
child: _ProfileAvatar(
avatarUrl: avatarUrl,
initial: initial,
isAgent: true,
),
)
: FractionallySizedBox(
widthFactor: 0.5,
child: _ProfileAvatar(
avatarUrl: avatarUrl,
initial: initial,
isAgent: profile?.isAgent == true,
),
),
),
if (!agentPresentation)
Positioned(
left: 0,
right: 0,
bottom: -(Grid.xl / 2),
child: _ProfilePresenceChip(presence: presence),
),
],
),
),
SizedBox(height: agentPresentation ? Grid.xs : Grid.half),
// Display name — centered, large
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
titleName,
style: agentPresentation
? context.textTheme.bodyLarge?.copyWith(
fontSize: 20,
height: 1.4,
letterSpacing: -0.2,
)
: context.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
),
),
],
),
),
if (secondaryName != null)
Padding(
padding: const EdgeInsets.only(top: Grid.xxs),
child: Center(
child: Text(
secondaryName,
textAlign: TextAlign.center,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
),
if (!agentPresentation && agentSubtitle != null) ...[
const SizedBox(height: Grid.xxs),
Center(
child: Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: Grid.half,
children: [
if (agentSubtitleIcon != null)
ExcludeSemantics(
child: IconTheme(
data: IconThemeData(
color: context.colors.onSurfaceVariant,
),
child: agentSubtitleIcon!,
),
),
Text(
agentSubtitle!,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
],
if (about.trim().isNotEmpty) ...[
const SizedBox(height: Grid.xxs),
SizedBox(
width: double.infinity,
child: Text(
about,
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
// Keep the current status centered beneath the profile identity.
if (userStatus != null && !userStatus.isEmpty)
SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.only(
top: Grid.xxs,
left: Grid.gutter,
right: Grid.gutter,
bottom: Grid.xxs,
),
child: MessageContent(
content: userStatus.text.isNotEmpty
? '${userStatus.emoji.isNotEmpty ? '${userStatus.emoji} ' : ''}${userStatus.text}'
: userStatus.emoji,
baseStyle: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 2,
),
),
),
// NIP-05 handle — centered, secondary
if (nip05 != null && nip05.isNotEmpty) ...[
const SizedBox(height: Grid.half),
Center(
child: Text(
nip05,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
// The 48-point target adds 8 points above the 32-point capsule.
SizedBox(
height: agentPresentation
? Grid.xxs
: primaryActionsSpacing,
),
if (agentPresentation) ...[
Center(
child: Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: Grid.xxs,
runSpacing: Grid.xxs,
children: [
if (pk != currentPubkey)
ProfileActionChip(
onPressed: isOpeningDirectMessage.value
? null
: openDirectMessage,
label: isOpeningDirectMessage.value
? 'Opening…'
: 'Message',
),
if (contextualActions != null)
Builder(builder: contextualActions!),
],
),
),
const SizedBox(height: Grid.sm),
if (agentIdentity != null)
Builder(builder: agentIdentity!),
AppListCard(
label: 'Public key',
horizontalPadding: 0,
children: [
Semantics(
button: true,
enabled: npub != null,
label: copied.value
? 'Copied public key'
: 'Copy public key',
value: npub,
liveRegion: true,
child: Tooltip(
message: 'Copy public key',
excludeFromSemantics: true,
child: InkWell(
onTap: npub == null ? null : copyPublicKey,
child: Padding(
padding: const EdgeInsets.all(Grid.xs),
child: ExcludeSemantics(
child: Row(
children: [
Expanded(
child: Text(
npub ?? 'Public key unavailable',
style: context.textTheme.bodySmall,
),
),
const SizedBox(width: Grid.xs),
AnimatedSwitcher(
duration:
MediaQuery.disableAnimationsOf(
context,
)
? Duration.zero
: const Duration(
milliseconds: 180,
),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder:
(child, animation) =>
ScaleTransition(
scale: animation,
child: child,
),
child: Icon(
copied.value
? BuzzIcons.check
: BuzzIcons.copy,
key: ValueKey(copied.value),
size: 18,
),
),
],
),
),
),
),
),
),
],
),
if (agentSettings != null)
Builder(builder: agentSettings!),
] else ...[
Row(
children: [
if (pk != currentPubkey) ...[
Expanded(
child: BuzzActionTile(
icon: isOpeningDirectMessage.value
? null
: BuzzIcons.messageSquare,
label: isOpeningDirectMessage.value
? 'Opening…'
: 'Message',
isLoading: isOpeningDirectMessage.value,
loadingSemanticLabel: 'Opening direct message',
onTap: openDirectMessage,
),
),
const SizedBox(width: Grid.twelve),
],
Expanded(
child: BuzzActionTile(
icon: copied.value
? BuzzIcons.check
: BuzzIcons.key,
label: copied.value
? 'Copied'
: 'Copy public key',
isEnabled: npub != null,
onTap: copyPublicKey,
),
),
],
),
SizedBox(height: contextualActionsSpacing),
if (contextualActions != null)
Builder(builder: contextualActions!),
],
],
),
),
),
),
],
),
);
if (!agentPresentation) return content;
return AgentArtworkPanel(
imageUrl: avatarUrl,
borderRadius: BorderRadius.zero,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BuzzSheetHeader(leading: headerLeading),
Flexible(child: SafeArea(top: false, child: content)),
],
),
);
}
}
/// The read-only version of the presence control in Settings. A viewed profile
/// reflects its owner's availability but does not allow another user to change
/// it.
class _ProfilePresenceChip extends StatelessWidget {
const _ProfilePresenceChip({required this.presence});
final String? presence;
@override
Widget build(BuildContext context) {
final effectivePresence = switch (presence) {
'online' || 'away' || 'offline' => presence,
_ => null,
};
final backgroundColor = switch (effectivePresence) {
'online' => context.appColors.success,
'away' => context.appColors.warning,
_ => context.colors.onSurfaceVariant,
};
final label = switch (effectivePresence) {
'online' => 'Online',
'away' => 'Away',
'offline' => 'Offline',
_ => 'Unknown',
};
return Semantics(
label: 'Presence: $label',
excludeSemantics: true,
child: SizedBox(
height: Grid.xl,
child: Center(
child: Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(Radii.full),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.xxs,
),
child: Text(
label,
style: filterChipTextStyle.copyWith(
color: context.colors.onPrimary,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
);
}
}
class _ProfileAvatar extends HookWidget {
final String? avatarUrl;
final String initial;
final bool isAgent;
const _ProfileAvatar({
required this.avatarUrl,
required this.initial,
required this.isAgent,
});
@override
Widget build(BuildContext context) {
final animatedAvatar = parseAnimatedAvatarUrl(avatarUrl);
final stoppedAnimationUrl = useState<String?>(null);
final isPlaying =
animatedAvatar != null &&
stoppedAnimationUrl.value != animatedAvatar.animationUrl;
return AspectRatio(
aspectRatio: 1,
child: GestureDetector(
key: const ValueKey('selected-profile-avatar'),
onTap: animatedAvatar == null
? null
: () => stoppedAnimationUrl.value =
stoppedAnimationUrl.value == animatedAvatar.animationUrl
? null
: animatedAvatar.animationUrl,
child: LayoutBuilder(
builder: (context, constraints) {
final avatar = isPlaying
? ProgressiveAnimatedAvatar(
key: ValueKey(animatedAvatar.animationUrl),
descriptor: animatedAvatar,
fallback: _AvatarFallback(initial: initial),
)
: AvatarImageContent(
imageUrl: animatedAvatar?.posterUrl ?? avatarUrl,
preferThumbnail: true,
fallback: _AvatarFallback(initial: initial),
);
return AvatarClip(
isAgent: isAgent,
child: !isAgent || !usesInsetAgentArtwork(avatarUrl)
? avatar
: Transform.scale(
scale: animatedAvatarArtworkScale,
child: avatar,
),
);
},
),
),
);
}
}
class _AvatarFallback extends StatelessWidget {
final String initial;
const _AvatarFallback({required this.initial});
@override
Widget build(BuildContext context) {
return Container(
color: context.controlSurface,
alignment: Alignment.center,
child: Text(
initial,
style: context.textTheme.displayLarge?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
);
}
}