mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Better skeletons in app detail page
This commit is contained in:
@@ -277,100 +277,108 @@ class _AppDetailContent extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Latest release section — shown as soon as version is known
|
||||
if (latestMetadata != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.2),
|
||||
// Latest release section — always shown; skeletons until metadata loads
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
child: Text(
|
||||
'LATEST RELEASE',
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface
|
||||
.withValues(alpha: 0.85),
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
latestMetadata != null
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
'LATEST RELEASE',
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface
|
||||
.withValues(alpha: 0.85),
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.2),
|
||||
).colorScheme.onSurface.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Version:',
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
Gap(4),
|
||||
Text(
|
||||
latestMetadata.version,
|
||||
style: context.textTheme.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Gap(4),
|
||||
Text(
|
||||
'(${formatDate(latestMetadata.createdAt)})',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
height: 32,
|
||||
width: 220,
|
||||
child: buildGradientLoader(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Version:',
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
Gap(4),
|
||||
Text(
|
||||
latestMetadata.version,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Gap(4),
|
||||
Text(
|
||||
'(${formatDate(latestMetadata.createdAt)})',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: latestRelease != null
|
||||
? ReleaseNotes(release: latestRelease)
|
||||
: const ReleaseNotesSkeleton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: latestRelease != null
|
||||
? ReleaseNotes(release: latestRelease)
|
||||
: const ReleaseNotesSkeleton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (latestMetadata != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppInfoTable(app: app, fileMetadata: latestMetadata),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppInfoTable(app: app, fileMetadata: latestMetadata),
|
||||
),
|
||||
|
||||
if (latestMetadata != null)
|
||||
CommentsSection(app: app, fileMetadata: latestMetadata),
|
||||
@@ -388,7 +396,7 @@ class _AppDetailContent extends HookConsumerWidget {
|
||||
),
|
||||
|
||||
// Sticky install button
|
||||
InstallButton(app: app, release: latestRelease),
|
||||
InstallButton(app: app),
|
||||
|
||||
// Floating three-dot menu
|
||||
_buildFloatingMenu(context, ref, app, isInstalled, isSignedIn),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:intl/intl.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/widgets/app_detail_widgets.dart';
|
||||
import 'package:zapstore/widgets/download_text_container.dart';
|
||||
import 'package:zapstore/theme.dart';
|
||||
|
||||
@@ -86,51 +87,56 @@ class AppInfoTable extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
if (fileMetadata?.hash != null) {
|
||||
final full = fileMetadata!.hash;
|
||||
rows.add(
|
||||
_InfoRow(label: 'File hash', value: full.abbreviate(), copyValue: full),
|
||||
);
|
||||
}
|
||||
if (fileMetadata != null) {
|
||||
if (fileMetadata!.hash.isNotEmpty) {
|
||||
final full = fileMetadata!.hash;
|
||||
rows.add(
|
||||
_InfoRow(label: 'File hash', value: full.abbreviate(), copyValue: full),
|
||||
);
|
||||
}
|
||||
|
||||
if (fileMetadata?.certificateHash != null) {
|
||||
final full = fileMetadata!.certificateHash!;
|
||||
rows.add(
|
||||
_InfoRow(
|
||||
label: 'Certificate hash',
|
||||
value: full.abbreviate(),
|
||||
copyValue: full,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
final installedVersionCode = installedPackage?.versionCode;
|
||||
final availableVersionCode = fileMetadata!.versionCode!;
|
||||
|
||||
rows.add(
|
||||
_InfoRow(
|
||||
label: 'Version code',
|
||||
value: availableVersionCode.toString(),
|
||||
valueWidget: _buildVersionCodePills(
|
||||
context,
|
||||
ref,
|
||||
installedVersionCode,
|
||||
availableVersionCode,
|
||||
if (fileMetadata!.certificateHash != null) {
|
||||
final full = fileMetadata!.certificateHash!;
|
||||
rows.add(
|
||||
_InfoRow(
|
||||
label: 'Certificate hash',
|
||||
value: full.abbreviate(),
|
||||
copyValue: full,
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
final installedPackage = app.installedPackage;
|
||||
final installedVersionCode = installedPackage?.versionCode;
|
||||
final availableVersionCode = fileMetadata!.versionCode!;
|
||||
|
||||
rows.add(
|
||||
_InfoRow(
|
||||
label: 'Version code',
|
||||
value: availableVersionCode.toString(),
|
||||
valueWidget: _buildVersionCodePills(
|
||||
context,
|
||||
ref,
|
||||
installedVersionCode,
|
||||
availableVersionCode,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
rows.add(const _InfoRowSkeleton(label: 'File hash'));
|
||||
rows.add(const _InfoRowSkeleton(label: 'Size'));
|
||||
rows.add(const _InfoRowSkeleton(label: 'Version code'));
|
||||
}
|
||||
|
||||
// Add release date as the last row
|
||||
// Release date — skeleton when release not yet loaded
|
||||
final release = app.latestRelease.value;
|
||||
if (release?.createdAt != null) {
|
||||
rows.add(
|
||||
@@ -139,6 +145,8 @@ class AppInfoTable extends HookConsumerWidget {
|
||||
value: DateFormat('MMM d, y').format(release!.createdAt),
|
||||
),
|
||||
);
|
||||
} else if (fileMetadata == null) {
|
||||
rows.add(const _InfoRowSkeleton(label: 'Release date'));
|
||||
}
|
||||
|
||||
return rows;
|
||||
@@ -225,6 +233,47 @@ class AppInfoTable extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRowSkeleton extends StatelessWidget {
|
||||
const _InfoRowSkeleton({required this.label});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: Colors.grey[600]),
|
||||
maxLines: 1,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: SizedBox(
|
||||
height: 14,
|
||||
width: 80,
|
||||
child: buildGradientLoader(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// SPDX id -> human friendly name (compact)
|
||||
const Map<String, String> _spdxIdToName = {
|
||||
'0BSD': 'BSD Zero Clause',
|
||||
|
||||
@@ -16,12 +16,10 @@ class InstallButton extends ConsumerWidget {
|
||||
const InstallButton({
|
||||
super.key,
|
||||
required this.app,
|
||||
this.release,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final App app;
|
||||
final Release? release;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
@@ -39,7 +37,6 @@ class InstallButton extends ConsumerWidget {
|
||||
isInstalled && (operation == null || operation is Completed);
|
||||
final hasUpdate = app.hasUpdate;
|
||||
final hasDowngrade = app.hasDowngrade;
|
||||
final hasRelease = release != null;
|
||||
final fileMetadata = app.installable;
|
||||
|
||||
// Listen for errors to show toasts
|
||||
@@ -59,7 +56,6 @@ class InstallButton extends ConsumerWidget {
|
||||
isInstalled: isInstalled,
|
||||
hasUpdate: hasUpdate,
|
||||
hasDowngrade: hasDowngrade,
|
||||
hasRelease: hasRelease,
|
||||
fileMetadata: fileMetadata,
|
||||
fontSize: fontSize,
|
||||
);
|
||||
@@ -103,7 +99,6 @@ class InstallButton extends ConsumerWidget {
|
||||
required bool isInstalled,
|
||||
required bool hasUpdate,
|
||||
required bool hasDowngrade,
|
||||
required bool hasRelease,
|
||||
required Installable? fileMetadata,
|
||||
required double fontSize,
|
||||
}) {
|
||||
@@ -243,13 +238,18 @@ class InstallButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
if (hasUpdate) {
|
||||
if (fileMetadata == null) {
|
||||
return _buildSimpleButton(
|
||||
context, 'Update', null,
|
||||
fontSize: fontSize,
|
||||
isDisabled: true,
|
||||
);
|
||||
}
|
||||
return _buildAsyncButton(
|
||||
context,
|
||||
ref,
|
||||
text: 'Update',
|
||||
onPressed: hasRelease && fileMetadata != null
|
||||
? () => _startDownload(context, ref, fileMetadata)
|
||||
: null,
|
||||
onPressed: () => _startDownload(context, ref, fileMetadata),
|
||||
fontSize: fontSize,
|
||||
);
|
||||
}
|
||||
@@ -266,13 +266,18 @@ class InstallButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
// Not installed
|
||||
if (fileMetadata == null) {
|
||||
return _buildSimpleButton(
|
||||
context, 'Install', null,
|
||||
fontSize: fontSize,
|
||||
isDisabled: true,
|
||||
);
|
||||
}
|
||||
return _buildAsyncButton(
|
||||
context,
|
||||
ref,
|
||||
text: 'Install',
|
||||
onPressed: hasRelease && fileMetadata != null
|
||||
? () => _startDownload(context, ref, fileMetadata)
|
||||
: null,
|
||||
onPressed: () => _startDownload(context, ref, fileMetadata),
|
||||
fontSize: fontSize,
|
||||
needsTrustCheck: true,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user