Better skeletons in app detail page

This commit is contained in:
franzap
2026-04-01 17:58:35 -03:00
parent 440b1a014d
commit a409025b26
3 changed files with 199 additions and 137 deletions
+93 -85
View File
@@ -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),
+90 -41
View File
@@ -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 -11
View File
@@ -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,
);