Files
zapstore/lib/widgets/app_card.dart
Henrique Velloso 84906164bb feat: auto-backup installed apps on install/uninstall
Back up the list of installed apps to Nostr (encrypted AppStack) when
batch installs complete or apps are uninstalled. Toggle in profile, off
by default. Restore screen fetches backup and lets users reinstall apps.

Changes by file:

- installed_apps_backup_service: Listener for batch completion and
  installed-list changes; debounced backup (3s); restore-only batches
  ignored; timer cancelled on provider dispose; logs reduced to errors
  and completion only; null-aware assignment for baseline keys.

- secure_storage_service: Persistence for backup-enabled toggle (off
  by default).

- profile_screen: Toggle to enable/disable backup; entry point for
  restore flow.

- restore_installed_apps_screen: Restore UI with shared AppBar; lists
  apps to install and already installed; cards navigate to app detail.

- main_scaffold: Watches backup listener provider so it stays active.

- package_manager: Removed unused installSourceProvider; install source
  handling for restore operations.

- install_operation, android_package_manager, install_button,
  batch_progress_banner: InstallSource.restore support for restore flow.

- app_card: Factory constructors (forRestore, forDiscovery, etc.) to
  simplify restore and other screens.

- router, installed_packages_snapshot: Supporting wiring for the feature.
2026-02-26 18:27:41 -03:00

642 lines
21 KiB
Dart

import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:go_router/go_router.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:skeletonizer/skeletonizer.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/url_utils.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/widgets/zap_widgets.dart';
import 'common/profile_avatar.dart';
import 'common/profile_name_widget.dart';
import 'version_pill_widget.dart';
import 'install_button.dart';
import '../theme.dart';
class AppCard extends HookConsumerWidget {
final App? app;
final bool isLoading;
final bool showUpdateArrow;
final bool showSignedBy;
final bool showUpdateButton;
final bool showZapEncouragement;
final bool showDescription;
final InstallSource installSource;
/// When provided, used instead of default navigation on tap.
/// Use when AppCard is shown outside GoRouter tree (e.g. in a dialog).
final VoidCallback? onTap;
/// When true, tap is disabled (e.g. informational display only).
final bool ignorePointer;
/// When true, show install button even when app has no update (e.g. restore "to install").
final bool showInstallWhenNotInstalled;
const AppCard({
super.key,
this.app,
this.isLoading = false,
this.showUpdateArrow = false,
this.showSignedBy = true,
this.showUpdateButton = false,
this.showZapEncouragement = false,
this.showDescription = true,
this.installSource = InstallSource.normal,
this.onTap,
this.ignorePointer = false,
this.showInstallWhenNotInstalled = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Show skeleton when loading or app is null
if (isLoading || app == null) {
return _buildSkeleton(context);
}
final currentApp = app!;
final needsPublisher = showSignedBy || showZapEncouragement;
final descriptionStyle = context.textTheme.bodyMedium?.copyWith(
height: 1.5,
color: AppColors.darkOnSurfaceSecondary,
);
final descriptionText = app!.description.isNotEmpty
? _stripMarkdown(app!.description)
: 'No description available';
Widget buildCard(Profile? publisher, bool isPublisherLoading) {
final card = GestureDetector(
onTap: ignorePointer
? null
: (onTap ??
() {
final segments = GoRouterState.of(context).uri.pathSegments;
final first = segments.isNotEmpty
? segments.first
: 'search';
final naddr = Utils.encodeShareableIdentifier(
AddressInput(
identifier: app!.identifier,
author: app!.pubkey,
kind: app!.event.kind,
relays: const [],
),
);
context.push('/$first/app/$naddr');
}),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row: Icon + Name/Version (icon matches header height)
LayoutBuilder(
builder: (context, constraints) {
// Icon takes a little over 20% of available width
final iconSize = (constraints.maxWidth * 0.21).clamp(
50.0,
68.0,
);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// App Icon (stretches to match name + version height, ~20% width)
_buildAppIcon(context, iconSize),
const SizedBox(width: 14),
// App Name and Version (always stacked)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// App name with optional "by publisher" inline
_buildAppNameWithPublisher(
context,
publisher,
isPublisherLoading,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: VersionPillWidget(
app: app!,
showUpdateArrow: showUpdateArrow,
),
),
],
),
),
],
);
},
),
// App Description rendered as plain text (markdown stripped)
if (showDescription) ...[
const SizedBox(height: 10),
Text(
descriptionText,
style: descriptionStyle,
overflow: TextOverflow.ellipsis,
maxLines: 2,
softWrap: true,
),
],
// Update button (for apps with updates or currently downloading/installing)
if (showUpdateButton || showInstallWhenNotInstalled)
LayoutBuilder(
builder: (context, constraints) {
// Match the text column start (icon width + spacing)
final iconSize = (constraints.maxWidth * 0.21).clamp(
50.0,
68.0,
);
final leftInset = iconSize + 14;
return Padding(
padding: EdgeInsets.only(left: leftInset),
child: SizedBox(
width: (constraints.maxWidth - leftInset).clamp(
0.0,
constraints.maxWidth,
),
child: _AppCardUpdateButtonSection(
app: app!,
installSource: installSource,
showWhenNotInstalled: showInstallWhenNotInstalled,
),
),
);
},
),
// Zap encouragement (only for downloading/installing developer-signed apps)
if (showZapEncouragement)
_AppCardZapEncouragementSection(
app: app!,
publisher: publisher,
),
],
),
),
);
return card;
}
if (!needsPublisher) return buildCard(null, false);
return Consumer(
builder: (context, ref, _) {
final publisherState = ref.watch(
query<Profile>(
authors: {currentApp.event.pubkey},
source: const LocalAndRemoteSource(
relays: {'social', 'vertex'},
cachedFor: Duration(hours: 2),
),
),
);
final publisher = publisherState.models.firstOrNull;
final isPublisherLoading =
publisherState is StorageLoading && publisher == null;
return buildCard(publisher, isPublisherLoading);
},
);
}
Widget _buildAppIcon(BuildContext context, double size) {
final iconUrl = firstValidHttpUrl(app!.icons);
return SizedBox(
width: size,
height: size,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: iconUrl != null
? CachedNetworkImage(
imageUrl: iconUrl,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 500),
fadeOutDuration: const Duration(milliseconds: 200),
placeholder: (_, url) => const SizedBox.shrink(),
errorWidget: (context, url, error) => Center(
child: Icon(
Icons.broken_image_outlined,
size: 32,
color: Colors.grey[400],
),
),
)
: Center(
child: Icon(
Icons.apps_outlined,
size: 32,
color: Colors.grey[400],
),
),
),
),
);
}
Widget _buildAppNameWithPublisher(
BuildContext context,
Profile? publisher,
bool isPublisherLoading,
) {
final appName = app!.name ?? app!.identifier;
final titleStyle = context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
);
// If not showing signed by or relay-signed, just show name
if (!showSignedBy || app!.isRelaySigned) {
return Text(
appName,
style: titleStyle,
overflow: TextOverflow.ellipsis,
maxLines: 1,
);
}
// Show "(app name) by (profile)" with wrapping
final byStyle = context.textTheme.bodyMedium?.copyWith(
color: AppColors.darkOnSurfaceSecondary,
);
final publisherStyle = byStyle?.copyWith(fontWeight: FontWeight.w600);
final avatarSize = context.textTheme.bodyMedium!.fontSize! * 1.4;
return Text.rich(
TextSpan(
children: [
TextSpan(text: appName, style: titleStyle),
TextSpan(text: ' by ', style: byStyle),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: const EdgeInsets.only(right: 4),
child: SizedBox(
width: avatarSize,
height: avatarSize,
child: ProfileAvatar(
profile: publisher,
radius: avatarSize / 2,
),
),
),
),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: ProfileNameWidget(
pubkey: app!.event.pubkey,
profile: publisher,
isLoading: isPublisherLoading,
style: publisherStyle,
skeletonWidth: 80,
),
),
],
),
softWrap: true,
overflow: TextOverflow.visible,
);
}
/// Parses markdown and returns plain text without formatting artifacts.
String _stripMarkdown(String input) {
final doc = md.Document(encodeHtml: false);
final nodes = doc.parseLines(input.split('\n'));
final buffer = StringBuffer();
void writeNode(md.Node node) {
if (node is md.Text) {
buffer.write(node.text);
return;
}
if (node is md.Element) {
// Preserve spacing for block-level elements
final isBlock = const {
'p',
'li',
'ul',
'ol',
'blockquote',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
}.contains(node.tag);
if (node.tag == 'br') buffer.write('\n');
for (final child in node.children ?? []) {
writeNode(child);
}
if (isBlock) buffer.write('\n');
}
}
for (final node in nodes) {
writeNode(node);
}
return buffer.toString().replaceAll(RegExp(r'\s+'), ' ').trim();
}
Widget _buildSkeleton(BuildContext context) {
return SkeletonizerConfig(
data: AppColors.getSkeletonizerConfig(Theme.of(context).brightness),
child: Skeletonizer(
enabled: true,
child: Container(
// Match actual content: vertical: 4
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
),
child: LayoutBuilder(
builder: (context, constraints) {
final iconSize = (constraints.maxWidth * 0.21).clamp(50.0, 68.0);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row: Icon + Name/Version
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// App icon skeleton
Container(
width: iconSize,
height: iconSize,
decoration: BoxDecoration(
color: AppColors.darkSkeletonBase,
borderRadius: BorderRadius.circular(14),
),
),
const SizedBox(width: 14),
// App name and version skeleton (stacked)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// App name skeleton (with "by publisher" space)
Container(
height: 20,
width: 180,
decoration: BoxDecoration(
color: AppColors.darkSkeletonBase,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 8),
// Version pill skeleton
Container(
height: 20,
width: 60,
decoration: BoxDecoration(
color: AppColors.darkSkeletonBase,
borderRadius: BorderRadius.circular(10),
),
),
],
),
),
],
),
// Description skeleton - 2 lines to match maxLines: 2
if (showDescription) ...[
const SizedBox(height: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 16,
width: double.infinity,
decoration: BoxDecoration(
color: AppColors.darkSkeletonBase,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 8),
Container(
height: 16,
width: 200,
decoration: BoxDecoration(
color: AppColors.darkSkeletonBase,
borderRadius: BorderRadius.circular(4),
),
),
],
),
],
],
);
},
),
),
),
);
}
}
class _AppCardUpdateButtonSection extends ConsumerWidget {
const _AppCardUpdateButtonSection({
required this.app,
this.installSource = InstallSource.normal,
this.showWhenNotInstalled = false,
});
final App app;
final InstallSource installSource;
final bool showWhenNotInstalled;
@override
Widget build(BuildContext context, WidgetRef ref) {
final operation = ref.watch(installOperationProvider(app.identifier));
final installedPkg = ref.watch(installedPackageProvider(app.identifier));
final hasOperation = operation != null;
final isInstalled = installedPkg != null;
final shouldShow =
app.hasUpdate || hasOperation || (showWhenNotInstalled && !isInstalled);
if (!shouldShow) return const SizedBox.shrink();
return Column(
children: [
const Gap(10),
ConstrainedBox(
constraints: const BoxConstraints(minHeight: 38),
child: _CompactInstallButton(
app: app,
release: app.latestRelease.value,
installSource: installSource,
),
),
],
);
}
}
class _AppCardZapEncouragementSection extends ConsumerWidget {
const _AppCardZapEncouragementSection({
required this.app,
required this.publisher,
});
final App app;
final Profile? publisher;
@override
Widget build(BuildContext context, WidgetRef ref) {
final operation = ref.watch(installOperationProvider(app.identifier));
final isActive = operation?.isActive ?? false;
final hasLud16 = publisher?.lud16?.trim().isNotEmpty ?? false;
final canZap = publisher != null && hasLud16;
final shouldShow = isActive && !app.isRelaySigned && canZap;
if (!shouldShow) return const SizedBox.shrink();
return Column(
children: [
const Gap(10),
_ZapEncouragementInCard(app: app, author: publisher),
],
);
}
}
/// Compact wrapper around InstallButton for use in app cards
class _CompactInstallButton extends ConsumerWidget {
final App app;
final Release? release;
final InstallSource installSource;
const _CompactInstallButton({
required this.app,
this.release,
this.installSource = InstallSource.normal,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return InstallButton(
app: app,
release: release,
compact: true,
installSource: installSource,
);
}
}
/// Zap encouragement widget shown inside app cards during installation
class _ZapEncouragementInCard extends HookConsumerWidget {
final App app;
final Profile? author;
const _ZapEncouragementInCard({required this.app, required this.author});
@override
Widget build(BuildContext context, WidgetRef ref) {
return InkWell(
onTap: () => showZapDialog(context, ref, app, author),
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Colors.orange.withValues(alpha: 0.2),
width: 1,
),
),
child: Row(
children: [
Expanded(
child: Text(
'Enjoying ${app.name ?? 'this app'}? Zap it!',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 8),
ConstrainedBox(
constraints: const BoxConstraints(minHeight: 28),
child: TextButton(
onPressed: () => showZapDialog(context, ref, app, author),
style: TextButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
child: Text(
'Zap',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
],
),
),
);
}
}