Files
buzz/mobile/lib/features/profile/settings_profile_header.dart
klopez4212 9aa332af03 Add inline profile camera capture (#6680)
## Summary

- add inline profile photo capture with smooth avatar-to-viewfinder
transitions
- add close, flip, shutter, retry, and use-photo states with haptics and
front-camera mirroring
- polish iOS liquid-glass controls and avatar editor motion

### Related issue

Follow-up to #6583.

### Testing

- just mobile-check
- just mobile-test (1,743 tests)
- built, installed, and launched on Pixel 10 and iPhone Air

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz>
Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz>
Co-authored-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 14:58:55 +01:00

404 lines
15 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/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/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 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: [
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),
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;
},
),
),
],
),
),
),
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));
}
}