mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Abstract profile identity row
This commit is contained in:
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user