App detail screen improvements

This commit is contained in:
franzap
2026-01-08 11:22:53 -03:00
parent 31871a20a5
commit 741d00b3dd
3 changed files with 165 additions and 17 deletions
+104
View File
@@ -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<Profile>(
authors: authorPubkeys,
source: const LocalSource(),
),
);
final profiles = switch (profilesState) {
StorageData(:final models) => models,
_ => <Profile>[],
};
// 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<AppStack> stacks,
Map<String, Profile> 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,
),
),
),
],
),
);
}
}
+11
View File
@@ -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;
+50 -17
View File
@@ -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) {