Include stacked/zapped profiles in app detail

This commit is contained in:
franzap
2026-04-08 18:51:33 -03:00
parent 430fa0f169
commit b69756ae9e
2 changed files with 196 additions and 41 deletions
+50 -41
View File
@@ -19,6 +19,7 @@ import 'package:zapstore/widgets/expandable_markdown.dart';
import 'package:zapstore/widgets/floating_overflow_menu.dart';
import 'package:zapstore/widgets/install_button.dart';
import 'package:zapstore/widgets/screenshots_gallery.dart';
import 'package:zapstore/widgets/stacked_by_row.dart';
class AppDetailScreen extends HookConsumerWidget {
const AppDetailScreen({super.key, required this.appId, this.authorPubkey});
@@ -178,48 +179,56 @@ class _AppDetailContent extends HookConsumerWidget {
child: AppHeader(app: app),
),
// Published by / Released at section
if (app.isRelaySigned && latestMetadata != null)
Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 8,
),
child: DownloadTextContainer(
url: latestMetadata.urls.first,
size: 14,
onTap: app.repository != null
? () => launchUrl(Uri.parse(app.repository!))
: null,
),
)
else
Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 8,
),
child: author != null
? AuthorContainer(
profile: author,
beforeText: 'Published by',
oneLine: true,
size: 14,
app: app,
onTap: () {
final segments = GoRouterState.of(
context,
).uri.pathSegments;
final first = segments.isNotEmpty
? segments.first
: 'search';
context.push('/$first/user/${author.pubkey}');
},
)
: const AuthorSkeleton(),
// Published by / Released at + Stacked & zapped by
Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 8,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (app.isRelaySigned && latestMetadata != null)
Padding(
padding: const EdgeInsets.all(4),
child: DownloadTextContainer(
url: latestMetadata.urls.first,
size: 14,
onTap: app.repository != null
? () => launchUrl(
Uri.parse(app.repository!))
: null,
),
)
else if (author != null)
AuthorContainer(
profile: author,
beforeText: 'Published by',
oneLine: true,
size: 14,
app: app,
onTap: () {
final segments = GoRouterState.of(
context,
).uri.pathSegments;
final first = segments.isNotEmpty
? segments.first
: 'search';
context.push(
'/$first/user/${author.pubkey}',
);
},
)
else
const AuthorSkeleton(),
Padding(
padding: const EdgeInsets.only(left: 4),
child: StackedByRow(app: app),
),
],
),
),
// Screenshots gallery
if (app.images.isNotEmpty)
+146
View File
@@ -0,0 +1,146 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/nostr_route.dart';
import 'package:zapstore/widgets/common/profile_avatar.dart';
const _kAvatarSize = 24.0;
const _kOverlap = 8.0;
class StackedByRow extends ConsumerWidget {
const StackedByRow({super.key, required this.app});
final App app;
@override
Widget build(BuildContext context, WidgetRef ref) {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
if (signedInPubkey == null) return const SizedBox.shrink();
final contactListState = ref.watch(
query<ContactList>(
authors: {signedInPubkey},
limit: 1,
source: const LocalSource(),
),
);
final followingPubkeys =
contactListState.models.firstOrNull?.followingPubkeys;
if (followingPubkeys == null || followingPubkeys.isEmpty) {
return const SizedBox.shrink();
}
// Followed users who included this app in a stack
final stacksState = ref.watch(
query<AppStack>(
tags: {
'#a': {app.id},
},
source: const LocalSource(),
subscriptionPrefix: 'app-stacked-by-${app.identifier}',
),
);
final followedPubkeys = stacksState.models
.map((s) => s.event.pubkey)
.where((pk) => pk != signedInPubkey && followingPubkeys.contains(pk))
.toSet();
// Followed users who zapped this app
final zapsState = ref.watch(
query<Zap>(
tags: app.event.addressableIdTagMap,
source: const LocalSource(),
subscriptionPrefix: 'app-stacked-by-zaps-${app.identifier}',
),
);
for (final zap in zapsState.models) {
final pk = zap.event.metadata['author'] as String?;
if (pk != null && pk != signedInPubkey && followingPubkeys.contains(pk)) {
followedPubkeys.add(pk);
}
}
if (followedPubkeys.isEmpty) return const SizedBox.shrink();
final profilesState = ref.watch(
query<Profile>(
authors: followedPubkeys,
source: const LocalAndRemoteSource(
relays: {'social', 'vertex'},
cachedFor: Duration(hours: 2),
),
subscriptionPrefix: 'app-stacked-by-profiles-${app.identifier}',
),
);
final profiles = profilesState.models;
if (profiles.isEmpty) return const SizedBox.shrink();
final baseStyle = context.textTheme.bodyMedium?.copyWith(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
);
return Padding(
padding: const EdgeInsets.only(top: 2, bottom: 4),
child: Row(
children: [
Text('Stacked or zapped by ', style: baseStyle),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: _OverlappingAvatars(
profiles: profiles,
onTap: (profile) => pushUser(context, profile.pubkey),
),
),
),
],
),
);
}
}
class _OverlappingAvatars extends StatelessWidget {
const _OverlappingAvatars({required this.profiles, this.onTap});
final List<Profile> profiles;
final void Function(Profile)? onTap;
@override
Widget build(BuildContext context) {
final totalWidth =
_kAvatarSize + (profiles.length - 1) * (_kAvatarSize - _kOverlap);
return SizedBox(
width: totalWidth,
height: _kAvatarSize,
child: Stack(
children: [
for (var i = profiles.length - 1; i >= 0; i--)
Positioned(
left: i * (_kAvatarSize - _kOverlap),
child: GestureDetector(
onTap: onTap != null ? () => onTap!(profiles[i]) : null,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).scaffoldBackgroundColor,
width: 1.5,
),
),
child: ProfileAvatar(
profile: profiles[i],
radius: (_kAvatarSize - 3) / 2,
),
),
),
),
],
),
);
}
}