Abstract profile identity row

This commit is contained in:
franzap
2026-01-31 16:02:22 -03:00
parent 57236c4427
commit b4b2ef4c81
3 changed files with 360 additions and 193 deletions
+64 -81
View File
@@ -17,7 +17,7 @@ import 'package:zapstore/main.dart';
import 'package:zapstore/services/bookmarks_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/common/profile_avatar.dart';
import 'package:zapstore/widgets/common/profile_identity_row.dart';
import 'package:zapstore/widgets/app_card.dart';
import 'package:zapstore/theme.dart';
import 'package:zapstore/services/notification_service.dart';
@@ -132,90 +132,73 @@ class _AuthenticationSection extends ConsumerWidget {
String pubkey,
Profile? profile,
) {
// Generate abbreviated npub for fallback display
final npub = Utils.encodeShareableFromString(pubkey, type: 'npub');
final abbreviatedNpub = npub.abbreviateNpub();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Profile Information
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProfileAvatar(profile: profile, pubkey: pubkey, radius: 32),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Name/npub
Text(
profile?.nameOrNpub ?? abbreviatedNpub,
style: context.textTheme.titleMedium,
),
const SizedBox(height: 8),
// Profile bio with NoteParser (non-interactive)
if ((profile?.about?.trim().isNotEmpty ?? false)) ...[
NoteParser.parse(
context,
profile!.about!,
textStyle: context.textTheme.bodySmall,
onNostrEntity: (entity) => NostrEntityWidget(
entity: entity,
colorPair: [
Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.secondary,
],
),
),
const SizedBox(height: 8),
],
// Link to full profile view
InkWell(
onTap: () => context.push('/profile/user/$pubkey'),
child: Row(
children: [
Text(
'View Full Profile',
style: context.textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_forward,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
const SizedBox(height: 12),
// Sign Out button
FilledButton.icon(
onPressed: () => _signOut(context, ref),
icon: const Icon(Icons.logout, size: 14),
label: const Text(
'Sign Out',
style: TextStyle(fontSize: 12),
),
style: FilledButton.styleFrom(
backgroundColor: Colors.red.shade900,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
minimumSize: const Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
// Profile identity row (avatar, name, npub, nip05)
ProfileIdentityRow(
pubkey: pubkey,
profile: profile,
avatarRadius: 32,
onCopiedNpub: () => context.showInfo('Copied npub to clipboard'),
),
const SizedBox(height: 12),
// Profile bio with NoteParser (non-interactive)
if ((profile?.about?.trim().isNotEmpty ?? false)) ...[
NoteParser.parse(
context,
profile!.about!,
textStyle: context.textTheme.bodySmall,
onNostrEntity: (entity) => NostrEntityWidget(
entity: entity,
colorPair: [
Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.secondary,
],
),
],
),
const SizedBox(height: 8),
],
// Link to full profile view
InkWell(
onTap: () => context.push('/profile/user/$pubkey'),
child: Row(
children: [
Text(
'View Full Profile',
style: context.textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_forward,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
const SizedBox(height: 12),
// Sign Out button
FilledButton.icon(
onPressed: () => _signOut(context, ref),
icon: const Icon(Icons.logout, size: 14),
label: const Text(
'Sign Out',
style: TextStyle(fontSize: 12),
),
style: FilledButton.styleFrom(
backgroundColor: Colors.red.shade900,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
minimumSize: const Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
);
+146 -112
View File
@@ -1,15 +1,16 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart';
import '../theme.dart';
import '../widgets/common/note_parser.dart';
import '../widgets/common/profile_avatar.dart';
import '../widgets/common/profile_name_widget.dart';
import '../widgets/common/profile_identity_row.dart';
import '../widgets/app_card.dart';
import '../widgets/zap_widgets.dart';
@@ -138,6 +139,10 @@ class UserScreen extends HookConsumerWidget {
return _StackLinkCard(stack: stack, pubkey: pubkey);
}, childCount: stacks.length),
),
// Republish stacks button - only for signed-in user viewing their own profile
SliverToBoxAdapter(
child: _RepublishStacksButton(stacks: stacks, pubkey: pubkey),
),
],
// Bottom padding
@@ -163,54 +168,11 @@ class _UserHeader extends StatelessWidget {
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
ProfileAvatar(profile: profile, pubkey: pubkey, radius: 40),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProfileNameWidget(
pubkey: pubkey,
profile: profile,
isLoading: isLoading,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 2,
skeletonWidth: 180,
),
const SizedBox(height: 4),
_NpubRow(pubkey: pubkey),
if (profile?.nip05 != null) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.verified,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 4),
Expanded(
child: Text(
profile!.nip05!,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
),
],
child: ProfileIdentityRow(
pubkey: pubkey,
profile: profile,
isLoading: isLoading,
avatarRadius: 40,
),
);
}
@@ -371,66 +333,6 @@ class _StackLinkCard extends StatelessWidget {
}
}
class _NpubRow extends StatelessWidget {
const _NpubRow({required this.pubkey});
final String pubkey;
@override
Widget build(BuildContext context) {
final npub = Utils.encodeShareableFromString(pubkey, type: 'npub');
// Longer abbreviation: show 12 chars from start + ... + 8 chars from end
final abbreviatedNpub =
'${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: npub));
},
child: Icon(
Icons.copy,
size: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 4),
GestureDetector(
onTap: () => launchUrl(
Uri.parse('https://npub.world/$npub'),
mode: LaunchMode.externalApplication,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.key,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
abbreviatedNpub,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 4),
Icon(
Icons.open_in_new,
size: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
],
);
}
}
class _UserBio extends HookWidget {
const _UserBio({required this.profile});
@@ -537,3 +439,135 @@ class _UserBio extends HookWidget {
);
}
}
class _RepublishStacksButton extends HookConsumerWidget {
const _RepublishStacksButton({
required this.stacks,
required this.pubkey,
});
final List<AppStack> stacks;
final String pubkey;
@override
Widget build(BuildContext context, WidgetRef ref) {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
// Only show if user is viewing their own profile
if (signedInPubkey == null || signedInPubkey != pubkey) {
return const SizedBox.shrink();
}
final isLoading = useState(false);
final progressCount = useState(0);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: FilledButton.icon(
onPressed: isLoading.value
? null
: () async {
isLoading.value = true;
progressCount.value = 0;
try {
final signer = ref.read(Signer.activeSignerProvider);
if (signer == null) {
if (context.mounted) {
context.showError('Sign in required');
}
return;
}
final platform =
ref.read(packageManagerProvider.notifier).platform;
for (final stack in stacks) {
// Get existing app IDs from the stack
final appIds = stack.event.getTagSetValues('a').toList();
// Check if it's an encrypted stack (has content but no 'a' tags)
final isEncrypted =
stack.content.isNotEmpty && appIds.isEmpty;
if (isEncrypted) {
// For encrypted stacks, decrypt, then re-encrypt with platform
try {
final decryptedContent = await signer.nip44Decrypt(
stack.content,
signedInPubkey,
);
final existingAppIds =
(jsonDecode(decryptedContent) as List)
.cast<String>();
final partialStack = PartialAppStack.withEncryptedApps(
name: stack.name ?? stack.identifier,
identifier: stack.identifier,
apps: existingAppIds,
platform: platform,
);
final signedStack =
await partialStack.signWith(signer);
await ref.storage.save({signedStack});
ref.storage.publish({
signedStack,
}, source: RemoteSource(relays: 'social'));
} catch (e) {
// Skip stacks that can't be decrypted
continue;
}
} else {
// For public stacks, rebuild with platform
final partialStack = PartialAppStack(
name: stack.name ?? stack.identifier,
identifier: stack.identifier,
platform: platform,
);
for (final appId in appIds) {
partialStack.addApp(appId);
}
final signedStack = await partialStack.signWith(signer);
await ref.storage.save({signedStack});
ref.storage.publish({
signedStack,
}, source: RemoteSource(relays: 'social'));
}
progressCount.value++;
}
if (context.mounted) {
context.showInfo(
'Republished ${progressCount.value} stacks with platform tag',
);
}
} catch (e) {
if (context.mounted) {
context.showError(
'Failed to republish stacks',
description: '$e',
);
}
} finally {
isLoading.value = false;
}
},
icon: isLoading.value
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.publish, size: 18),
label: Text(
isLoading.value
? 'Republishing ${progressCount.value}/${stacks.length}...'
: 'Republish stacks (${stacks.length})',
),
),
);
}
}
@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:models/models.dart';
import 'package:url_launcher/url_launcher.dart';
import 'profile_avatar.dart';
import 'profile_name_widget.dart';
/// Horizontal profile identity row with avatar, name, npub, and nip05.
/// Used in profile screens for consistent display of user identity.
class ProfileIdentityRow extends StatelessWidget {
const ProfileIdentityRow({
super.key,
required this.pubkey,
this.profile,
this.isLoading = false,
this.avatarRadius = 32,
this.onCopiedNpub,
});
final String pubkey;
final Profile? profile;
final bool isLoading;
final double avatarRadius;
final VoidCallback? onCopiedNpub;
@override
Widget build(BuildContext context) {
final npub = Utils.encodeShareableFromString(pubkey, type: 'npub');
final abbreviatedNpub =
'${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}';
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProfileAvatar(profile: profile, pubkey: pubkey, radius: avatarRadius),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Name
ProfileNameWidget(
pubkey: pubkey,
profile: profile,
isLoading: isLoading,
style: Theme.of(context).textTheme.titleMedium,
skeletonWidth: 180,
),
const SizedBox(height: 4),
// Npub row with open and copy
_NpubRow(
npub: npub,
abbreviatedNpub: abbreviatedNpub,
onCopied: onCopiedNpub,
),
// NIP-05
if (profile?.nip05 != null) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.verified,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 4),
Expanded(
child: Text(
profile!.nip05!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
),
],
);
}
}
class _NpubRow extends StatelessWidget {
const _NpubRow({
required this.npub,
required this.abbreviatedNpub,
this.onCopied,
});
final String npub;
final String abbreviatedNpub;
final VoidCallback? onCopied;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// Npub with external link
GestureDetector(
onTap: () => launchUrl(
Uri.parse('https://npub.world/$npub'),
mode: LaunchMode.externalApplication,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.key,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
abbreviatedNpub,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 4),
Icon(
Icons.open_in_new,
size: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
const SizedBox(width: 8),
// Copy button
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: npub));
onCopied?.call();
},
child: Icon(
Icons.copy,
size: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
);
}
}