mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
416 lines
12 KiB
Dart
416 lines
12 KiB
Dart
import 'package:auto_size_text/auto_size_text.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
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/author_container.dart';
|
|
import 'package:zapstore/widgets/download_text_container.dart';
|
|
import 'package:zapstore/theme.dart';
|
|
|
|
class AppInfoTable extends HookConsumerWidget {
|
|
const AppInfoTable({
|
|
super.key,
|
|
required this.app,
|
|
this.fileMetadata,
|
|
this.identityProof,
|
|
this.identityProfile,
|
|
this.isIdentityProfileLoading = false,
|
|
});
|
|
|
|
final App app;
|
|
final Installable? fileMetadata;
|
|
final CryptographicIdentityProof? identityProof;
|
|
final Profile? identityProfile;
|
|
final bool isIdentityProfileLoading;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Column(children: _buildInfoRows(context, ref)),
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildInfoRows(BuildContext context, WidgetRef ref) {
|
|
final rows = <Widget>[];
|
|
|
|
rows.add(
|
|
_InfoRow(
|
|
label: 'Source',
|
|
value: app.repository ?? 'Not available',
|
|
valueWidget: app.repository != null
|
|
? Flexible(
|
|
child: GestureDetector(
|
|
onTap: () => launchUrl(Uri.parse(app.repository!)),
|
|
child: DownloadTextContainer(
|
|
url: app.repository!,
|
|
beforeText: '',
|
|
oneLine: true,
|
|
showFullUrl: false,
|
|
size: context.textTheme.bodyMedium?.fontSize,
|
|
),
|
|
),
|
|
)
|
|
: Flexible(
|
|
child: Text(
|
|
'Not available',
|
|
style: context.textTheme.bodyMedium?.copyWith(
|
|
color: Colors.red[300],
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
textAlign: TextAlign.right,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
if (app.license != null) {
|
|
final licenseId = app.license!;
|
|
final licenseName = licenseId == 'NOASSERTION'
|
|
? 'N/A'
|
|
: (_spdxIdToName[licenseId] ?? licenseId);
|
|
rows.add(_InfoRow(label: 'License', value: licenseName));
|
|
}
|
|
|
|
rows.add(_InfoRow(label: 'App ID', value: app.identifier));
|
|
|
|
if (identityProof != null) {
|
|
final npub = Utils.encodeShareableFromString(
|
|
identityProof!.pubkey,
|
|
type: 'npub',
|
|
);
|
|
rows.add(
|
|
_InfoRow(
|
|
label: 'Linked identity',
|
|
value: npub.abbreviateNpub(),
|
|
copyValue: npub,
|
|
valueWidget: Flexible(
|
|
child: Align(
|
|
alignment: Alignment.centerRight,
|
|
child: AuthorContainer(
|
|
profile: identityProfile,
|
|
pubkey: identityProof!.pubkey,
|
|
oneLine: true,
|
|
size: context.textTheme.bodyMedium?.fontSize,
|
|
isLoading: isIdentityProfileLoading,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (!app.isRelaySigned) {
|
|
// Author npub
|
|
final npub = Utils.encodeShareableFromString(app.pubkey, type: 'npub');
|
|
rows.add(
|
|
_InfoRow(
|
|
label: 'Author npub',
|
|
value: npub.abbreviate(),
|
|
copyValue: npub,
|
|
),
|
|
);
|
|
}
|
|
|
|
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) {
|
|
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'));
|
|
}
|
|
|
|
// Release date — skeleton when release not yet loaded
|
|
final release = app.latestRelease.value;
|
|
if (release?.createdAt != null) {
|
|
rows.add(
|
|
_InfoRow(
|
|
label: 'Release date',
|
|
value: DateFormat('MMM d, y').format(release!.createdAt),
|
|
),
|
|
);
|
|
} else if (fileMetadata == null) {
|
|
rows.add(const _InfoRowSkeleton(label: 'Release date'));
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
Widget _buildVersionCodePills(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
int? installedVersionCode,
|
|
int availableVersionCode,
|
|
) {
|
|
// If no installed version or same version code, show single pill
|
|
if (installedVersionCode == null ||
|
|
installedVersionCode == availableVersionCode) {
|
|
return Flexible(
|
|
child: _buildVersionCodePill(
|
|
context,
|
|
availableVersionCode.toString(),
|
|
AppColors.darkPillBackground,
|
|
Colors.white,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Show dual pills for upgrade/downgrade
|
|
final isDowngrade = availableVersionCode < installedVersionCode;
|
|
|
|
return Flexible(
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Installed version pill (muted)
|
|
_buildVersionCodePill(
|
|
context,
|
|
installedVersionCode.toString(),
|
|
Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
|
|
Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
|
|
// Arrow
|
|
Icon(
|
|
Icons.arrow_right,
|
|
size: 16,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
|
|
// Available version pill (highlighted for update, muted for downgrade)
|
|
_buildVersionCodePill(
|
|
context,
|
|
availableVersionCode.toString(),
|
|
isDowngrade
|
|
? Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)
|
|
: AppColors.darkPillBackground,
|
|
isDowngrade
|
|
? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5)
|
|
: Colors.white,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildVersionCodePill(
|
|
BuildContext context,
|
|
String versionCode,
|
|
Color backgroundColor,
|
|
Color textColor,
|
|
) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: backgroundColor,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(
|
|
versionCode,
|
|
style: context.textTheme.labelSmall?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: textColor,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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',
|
|
'Apache-2.0': 'Apache 2.0',
|
|
'MIT': 'MIT',
|
|
'BSD-2-Clause': 'BSD 2-Clause "Simplified" License',
|
|
'BSD-3-Clause': 'BSD 3-Clause "New" or "Revised" License',
|
|
'GPL-2.0-only': 'GNU GPL v2.0 only',
|
|
'GPL-2.0-or-later': 'GNU GPL v2.0 or later',
|
|
'GPL-3.0-only': 'GNU GPL v3.0 only',
|
|
'GPL-3.0-or-later': 'GNU GPL v3.0 or later',
|
|
'LGPL-2.1-only': 'GNU LGPL v2.1 only',
|
|
'LGPL-2.1-or-later': 'GNU LGPL v2.1 or later',
|
|
'LGPL-3.0-only': 'GNU LGPL v3.0 only',
|
|
'LGPL-3.0-or-later': 'GNU LGPL v3.0 or later',
|
|
'AGPL-3.0-only': 'GNU AGPL v3.0 only',
|
|
'AGPL-3.0-or-later': 'GNU AGPL v3.0 or later',
|
|
'MPL-2.0': 'Mozilla Public License 2.0',
|
|
'Unlicense': 'The Unlicense',
|
|
'CC0-1.0': 'CC0 1.0',
|
|
'CDDL-1.0': 'CDDL 1.0',
|
|
'EUPL-1.2': 'EUPL 1.2',
|
|
'BSL-1.0': 'Boost 1.0',
|
|
'ISC': 'ISC',
|
|
'Zlib': 'zlib',
|
|
'Artistic-2.0': 'Artistic 2.0',
|
|
'NCSA': 'NCSA',
|
|
'WTFPL': 'WTFPL',
|
|
};
|
|
|
|
class _InfoRow extends StatelessWidget {
|
|
const _InfoRow({
|
|
required this.label,
|
|
required this.value,
|
|
this.copyValue,
|
|
this.valueWidget,
|
|
});
|
|
final String label;
|
|
final String value;
|
|
final String? copyValue;
|
|
final Widget? valueWidget;
|
|
|
|
@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,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
valueWidget != null
|
|
? valueWidget!
|
|
: Flexible(
|
|
child: AutoSizeText(
|
|
value,
|
|
style: context.textTheme.bodyMedium,
|
|
maxLines: 1,
|
|
textAlign: TextAlign.right,
|
|
minFontSize: 12,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
if (copyValue != null) ...[
|
|
const SizedBox(width: 6),
|
|
IconButton(
|
|
icon: const Icon(Icons.copy, size: 16),
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
style: IconButton.styleFrom(
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
padding: EdgeInsets.zero,
|
|
minimumSize: Size.zero,
|
|
),
|
|
onPressed: () {
|
|
Clipboard.setData(ClipboardData(text: copyValue!));
|
|
},
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|