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