From b69756ae9e6d57b14870edb8cb2bd282fa53479a Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Wed, 8 Apr 2026 18:51:33 -0300 Subject: [PATCH] Include stacked/zapped profiles in app detail --- lib/screens/app_detail_screen.dart | 91 ++++++++++-------- lib/widgets/stacked_by_row.dart | 146 +++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 41 deletions(-) create mode 100644 lib/widgets/stacked_by_row.dart diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index 2fff4c1..6359172 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -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) diff --git a/lib/widgets/stacked_by_row.dart b/lib/widgets/stacked_by_row.dart new file mode 100644 index 0000000..f571584 --- /dev/null +++ b/lib/widgets/stacked_by_row.dart @@ -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( + 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( + 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( + 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( + 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 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, + ), + ), + ), + ), + ], + ), + ); + } +}