From 741d00b3dd751ab3922a02680c55ebd93c7ccce9 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Thu, 8 Jan 2026 11:22:53 -0300 Subject: [PATCH] App detail screen improvements --- lib/screens/app_detail_screen.dart | 104 +++++++++++++++++++++++ lib/widgets/app_info_table.dart | 11 +++ lib/widgets/download_text_container.dart | 67 +++++++++++---- 3 files changed, 165 insertions(+), 17 deletions(-) diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index 7de82a3..033c4e5 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -23,6 +23,7 @@ import 'package:zapstore/widgets/download_text_container.dart'; import 'package:zapstore/widgets/expandable_markdown.dart'; import 'package:zapstore/widgets/install_button.dart'; import 'package:zapstore/widgets/screenshots_gallery.dart'; +import 'package:zapstore/widgets/common/profile_avatar.dart'; class AppDetailScreen extends HookConsumerWidget { const AppDetailScreen({super.key, required this.appId, this.authorPubkey}); @@ -192,6 +193,9 @@ class _AppDetailContent extends HookConsumerWidget { child: DownloadTextContainer( url: latestMetadata.urls.first, size: 14, + onTap: app.repository != null + ? () => launchUrl(Uri.parse(app.repository!)) + : null, ), ) else @@ -253,6 +257,9 @@ class _AppDetailContent extends HookConsumerWidget { ), ), + // Included in stacks section + _IncludedInStacksRow(app: app), + // Social action buttons Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -710,3 +717,100 @@ class _AppDetailContent extends HookConsumerWidget { } } } + +/// Widget showing which stacks include this app (local only) +class _IncludedInStacksRow extends HookConsumerWidget { + final App app; + + const _IncludedInStacksRow({required this.app}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Get stacks from the app's relationship (LocalSource only) + final stacks = app.appStacks.toList(); + + if (stacks.isEmpty) { + return const SizedBox.shrink(); + } + + // Get unique pubkeys from stack authors + final authorPubkeys = stacks.map((s) => s.pubkey).toSet(); + + // Query profiles for stack authors + final profilesState = ref.watch( + query( + authors: authorPubkeys, + source: const LocalSource(), + ), + ); + + final profiles = switch (profilesState) { + StorageData(:final models) => models, + _ => [], + }; + + // Create a map of pubkey -> profile for easy lookup + final profileMap = {for (final p in profiles) p.pubkey: p}; + + return Padding( + padding: const EdgeInsets.only(left: 16, right: 16, top: 8), + child: Row( + children: [ + // Stack of overlapping profile avatars (each tappable to its stack) + SizedBox( + height: 24, + child: _buildStackedAvatars(context, stacks, profileMap), + ), + const SizedBox(width: 8), + Text( + 'added this app to their stacks', + style: context.textTheme.bodyMedium?.copyWith( + color: + Theme.of(context).colorScheme.onSurface.withValues( + alpha: 0.7, + ), + ), + ), + ], + ), + ); + } + + Widget _buildStackedAvatars( + BuildContext context, + List stacks, + Map profileMap, + ) { + final avatarRadius = 12.0; + final overlap = 4.0; + final maxAvatars = 5; + + final stackList = stacks.take(maxAvatars).toList(); + final totalWidth = + (avatarRadius * 2) + ((stackList.length - 1) * (avatarRadius * 2 - overlap)); + + return SizedBox( + width: totalWidth, + child: Stack( + children: [ + for (var i = 0; i < stackList.length; i++) + Positioned( + left: i * (avatarRadius * 2 - overlap), + child: GestureDetector( + onTap: () { + final pubkey = stackList[i].pubkey; + final segments = GoRouterState.of(context).uri.pathSegments; + final first = segments.isNotEmpty ? segments.first : 'search'; + context.push('/$first/user/$pubkey'); + }, + child: ProfileAvatar( + profile: profileMap[stackList[i].pubkey], + radius: avatarRadius, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/app_info_table.dart b/lib/widgets/app_info_table.dart index fdcd5c2..4a35353 100644 --- a/lib/widgets/app_info_table.dart +++ b/lib/widgets/app_info_table.dart @@ -93,6 +93,17 @@ class AppInfoTable extends HookConsumerWidget { ); } + if (fileMetadata?.size != null) { + final sizeInBytes = fileMetadata!.size!; + final sizeInMB = (sizeInBytes / (1024 * 1024)).toStringAsFixed(2); + rows.add( + _InfoRow( + label: 'Size', + value: '$sizeInMB MB', + ), + ); + } + if (fileMetadata?.versionCode != null) { // Get installed package info for comparison final installedPackage = app.installedPackage; diff --git a/lib/widgets/download_text_container.dart b/lib/widgets/download_text_container.dart index 02ee6ff..2e8f9ae 100644 --- a/lib/widgets/download_text_container.dart +++ b/lib/widgets/download_text_container.dart @@ -35,36 +35,69 @@ class DownloadTextContainer extends StatelessWidget { ); final boldStyle = baseStyle?.copyWith(fontWeight: FontWeight.w600); - final richText = Text.rich( + // If no onTap, render as simple rich text + if (onTap == null) { + return Text.rich( + TextSpan( + children: [ + TextSpan(text: beforeText, style: baseStyle), + const TextSpan(text: ' '), + if (iconWidget != null) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: iconWidget, + ), + ), + TextSpan(text: pathText, style: boldStyle), + ], + ), + softWrap: !oneLine, + overflow: oneLine ? TextOverflow.ellipsis : TextOverflow.visible, + maxLines: oneLine ? 1 : null, + ); + } + + // With onTap: make only the URL portion (icon + path) tappable + return Text.rich( TextSpan( children: [ TextSpan(text: beforeText, style: baseStyle), const TextSpan(text: ' '), - if (iconWidget != null) - WidgetSpan( - alignment: PlaceholderAlignment.middle, + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), child: Padding( - padding: const EdgeInsets.only(right: 4), - child: iconWidget, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (iconWidget != null) ...[ + iconWidget, + const SizedBox(width: 4), + ], + Flexible( + child: Text( + pathText, + style: boldStyle, + overflow: oneLine ? TextOverflow.ellipsis : TextOverflow.visible, + maxLines: oneLine ? 1 : null, + ), + ), + ], + ), ), ), - TextSpan(text: pathText, style: boldStyle), + ), ], ), softWrap: !oneLine, overflow: oneLine ? TextOverflow.ellipsis : TextOverflow.visible, maxLines: oneLine ? 1 : null, ); - - if (onTap != null) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Padding(padding: const EdgeInsets.all(4), child: richText), - ); - } - - return richText; } Uri? _tryParseUri(String value) {