mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
## 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>
489 lines
17 KiB
Dart
489 lines
17 KiB
Dart
import 'dart:async';
|
|
|
|
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:hooks_riverpod/misc.dart' show ProviderListenable;
|
|
import 'package:buzz/shared/theme/buzz_icons.dart';
|
|
|
|
import '../../shared/animated_avatar.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_image.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 or a Pulse
|
|
/// timeline). 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),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
class UserProfileSheet extends HookConsumerWidget {
|
|
final String pubkey;
|
|
|
|
/// 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;
|
|
|
|
const UserProfileSheet({
|
|
super.key,
|
|
required this.pubkey,
|
|
this.names,
|
|
this.contextualActions,
|
|
});
|
|
|
|
@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(presenceCacheProvider);
|
|
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 avatarUrl = profile?.avatarUrl;
|
|
final nip05 = profile?.nip05Handle;
|
|
final initial =
|
|
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
|
|
|
|
Future<void> copyPublicKey() async {
|
|
if (npub == null) return;
|
|
await Clipboard.setData(ClipboardData(text: npub));
|
|
if (!context.mounted) return;
|
|
copied.value = true;
|
|
_showProfileCopyToast(context);
|
|
unawaited(
|
|
Future<void>.delayed(const Duration(seconds: 2), () {
|
|
if (context.mounted) copied.value = false;
|
|
}),
|
|
);
|
|
}
|
|
|
|
Future<void> openDirectMessage() async {
|
|
if (isOpeningDirectMessage.value) return;
|
|
isOpeningDirectMessage.value = true;
|
|
try {
|
|
final channel = await ref
|
|
.read(channelActionsProvider)
|
|
.openDm(pubkeys: [pk]);
|
|
if (!context.mounted) return;
|
|
Navigator.of(context).pop(channel);
|
|
} catch (_) {
|
|
// Silently fail — user tapped but DM open failed.
|
|
if (context.mounted) isOpeningDirectMessage.value = false;
|
|
}
|
|
}
|
|
|
|
return 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: const EdgeInsets.only(bottom: Grid.xl / 2),
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Align(
|
|
alignment: Alignment.center,
|
|
child: FractionallySizedBox(
|
|
widthFactor: 0.5,
|
|
child: _ProfileAvatar(
|
|
avatarUrl: avatarUrl,
|
|
initial: initial,
|
|
isAgent: profile?.isAgent == true,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: -(Grid.xl / 2),
|
|
child: _ProfilePresenceChip(presence: presence),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: Grid.half),
|
|
|
|
// Display name — centered, large
|
|
Center(
|
|
child: Text(
|
|
displayName,
|
|
style: context.textTheme.headlineSmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
|
|
const SizedBox(height: Grid.xs + Grid.half),
|
|
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: Grid.xs),
|
|
if (contextualActions != null)
|
|
Builder(builder: contextualActions!),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Shows clipboard feedback above the bottom-sheet route. A regular SnackBar
|
|
/// belongs to the page Scaffold, which sits behind the modal sheet.
|
|
void _showProfileCopyToast(BuildContext context) {
|
|
final overlay = Overlay.of(context, rootOverlay: true);
|
|
final colors = context.colors;
|
|
final textStyle = context.textTheme.bodyMedium?.copyWith(
|
|
color: colors.onInverseSurface,
|
|
);
|
|
late final OverlayEntry entry;
|
|
entry = OverlayEntry(
|
|
builder: (overlayContext) => Positioned(
|
|
left: Grid.gutter,
|
|
right: Grid.gutter,
|
|
bottom: MediaQuery.viewPaddingOf(overlayContext).bottom + Grid.xs,
|
|
child: Material(
|
|
color: colors.inverseSurface,
|
|
elevation: 6,
|
|
borderRadius: BorderRadius.circular(Radii.lg),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: Grid.xs,
|
|
vertical: Grid.twelve,
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(BuzzIcons.check, size: 18, color: colors.onInverseSurface),
|
|
const SizedBox(width: Grid.xxs),
|
|
Text('Public key copied', style: textStyle),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
overlay.insert(entry);
|
|
Future<void>.delayed(const Duration(seconds: 2), () {
|
|
if (entry.mounted) entry.remove();
|
|
});
|
|
}
|
|
|
|
/// 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,
|
|
fallback: _AvatarFallback(initial: initial),
|
|
);
|
|
if (!isAgent) return ClipOval(child: avatar);
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(
|
|
constraints.biggest.shortestSide * 0.3,
|
|
),
|
|
child: !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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|