Files
zapstore/lib/widgets/install_button.dart
T

849 lines
27 KiB
Dart

import 'package:async_button_builder/async_button_builder.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/services/download_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/trust_service.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/author_container.dart';
import 'package:zapstore/widgets/common/base_dialog.dart';
import 'package:zapstore/widgets/install_alert_dialog.dart';
import 'package:zapstore/widgets/install_button_state.dart';
import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/theme.dart';
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
Widget build(BuildContext context, WidgetRef ref) {
final downloadInfo = ref.watch(downloadInfoProvider(app.identifier));
final installedPackage = ref
.watch(packageManagerProvider)
.where((p) => p.appId == app.identifier)
.firstOrNull;
// Listen for installation failures and show errors
ref.listen(downloadInfoProvider(app.identifier), (previous, next) {
// Installation just failed - show error
if (next != null &&
next.isReadyToInstall &&
next.errorDetails != null &&
previous?.errorDetails != next.errorDetails) {
if (context.mounted) {
final errorMessage = next.errorDetails == 'CERTIFICATE_MISMATCH'
? 'Installation failed: Certificate mismatch detected'
: 'Installation failed: ${next.errorDetails}';
context.showError(errorMessage);
}
}
});
// Determine current state from inputs using the extracted function
final state = determineInstallButtonState(
app: app,
installedPackage: installedPackage,
downloadInfo: downloadInfo,
release: release,
formatTotalSizeMb: _formatTotalSizeMb,
);
// Compact mode - just the button without positioning or extra actions
if (compact) {
return _buildButtonForState(context, ref, state);
}
// Regular mode - positioned at bottom with extra action buttons
return Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
),
child: SafeArea(
child: Row(
children: [
Expanded(
child: SizedBox(
height: 48,
child: _buildButtonForState(context, ref, state),
),
),
// Show action buttons only for installed apps
if (state is InstalledUpToDate ||
state is UpdateAvailable ||
state is DowngradeBlocked) ...[
if (state is UpdateAvailable) ...[
const SizedBox(width: 8),
_buildOpenIconButton(context, ref),
],
const SizedBox(width: 8),
_buildUninstallIconButton(context, ref),
],
],
),
),
),
);
}
/// Builds the appropriate button widget based on the current state
/// Uses pattern matching on sealed class for exhaustive handling
Widget _buildButtonForState(
BuildContext context,
WidgetRef ref,
InstallButtonState state,
) {
final fontSize = compact ? 13.0 : 16.0;
return switch (state) {
// Not installed, ready to install
ReadyToInstall(:final hasRelease) => _buildAsyncButton(
context,
ref,
text: 'Install',
onPressed: hasRelease ? () => _startDownload(ref) : null,
isPrimary: true,
fontSize: fontSize,
needsTrustCheck: true,
),
// Installed and up to date
InstalledUpToDate() => _buildAsyncButton(
context,
ref,
text: 'Open',
onPressed: () => _openApp(context, ref),
isPrimary: true,
fontSize: fontSize,
needsTrustCheck: false,
),
// Update available
UpdateAvailable(:final hasRelease) => _buildAsyncButton(
context,
ref,
text: 'Update',
onPressed: hasRelease ? () => _startDownload(ref) : null,
isPrimary: true,
fontSize: fontSize,
needsTrustCheck: false,
),
// Downgrade blocked
DowngradeBlocked() => _buildSimpleButton(
context,
"Can't downgrade",
null,
isPrimary: false,
showSpinner: false,
fontSize: fontSize,
isDowngrade: true,
),
// Download in progress
Downloading(:final progress, :final totalSizeMb) => _buildProgressButton(
context,
ref,
progress: progress,
text: _formatDownloadProgress(progress, totalSizeMb),
fontSize: fontSize,
onTap: () => _pauseDownload(ref),
),
// Download paused
DownloadPaused(:final progress, :final totalSizeMb) =>
_buildProgressButton(
context,
ref,
progress: progress,
text: _formatDownloadProgress(progress, totalSizeMb, paused: true),
fontSize: fontSize,
onTap: () => _resumeDownload(ref),
),
// Download enqueued
DownloadEnqueued(:final isUpdate) => _buildSimpleButton(
context,
isUpdate ? 'Update' : 'Install',
() => _cancelAndRestart(ref),
isPrimary: true,
fontSize: fontSize,
),
// Downloaded, ready to install
DownloadedReadyToInstall(:final isUpdate) => AsyncButtonBuilder(
onPressed: () => _installFromDownloaded(ref),
builder: (context, child, callback, buttonState) {
return _buildSimpleButton(
context,
buttonState.maybeWhen(
loading: () => 'Installing...',
orElse: () => isUpdate ? 'Update' : 'Install',
),
buttonState.maybeWhen(loading: () => null, orElse: () => callback),
isPrimary: true,
showSpinner: buttonState.maybeWhen(
loading: () => true,
orElse: () => false,
),
fontSize: fontSize,
);
},
child: Text(
isUpdate ? 'Update' : 'Install',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
onError: () {
if (context.mounted) {
context.showError('Installation failed. Please try again.');
}
},
),
// Certificate mismatch - requires force update
ForceUpdateRequired() => _buildSimpleButton(
context,
'Force update',
() => _showForceUpdateDialog(ref, context),
isPrimary: false,
fontSize: fontSize,
isError: true,
),
// Installing
Installing() => _buildSimpleButton(
context,
'Requesting installation',
null,
isPrimary: true,
showSpinner: true,
fontSize: fontSize,
),
// Failed
Failed(:final canRetryReckless, :final downloadInfo) =>
_buildSimpleButton(
context,
'Error (tap for details)',
() => _handleErrorTap(ref, context, downloadInfo, canRetryReckless),
isPrimary: false,
isError: true,
fontSize: fontSize,
),
};
}
/// Helper to format download progress text
String _formatDownloadProgress(
double progress,
String? totalSizeMb, {
bool paused = false,
}) {
final percent = (progress * 100).round();
final pausedSuffix = paused ? ' (paused)' : '';
return totalSizeMb != null
? '$percent% of $totalSizeMb$pausedSuffix'
: '$percent%$pausedSuffix';
}
/// Builds async button with trust check for fresh installs
Widget _buildAsyncButton(
BuildContext context,
WidgetRef ref, {
required String text,
required Future<void> Function()? onPressed,
required bool isPrimary,
required double fontSize,
required bool needsTrustCheck,
}) {
// Store error message for display
String? lastError;
return AsyncButtonBuilder(
onPressed: onPressed == null
? null
: () async {
// Trust check only for fresh installs
if (needsTrustCheck) {
final signerPubkey = app.author.value?.pubkey;
bool shouldShowDialog = true;
if (signerPubkey != null) {
try {
final isTrusted = await ref
.read(trustServiceProvider)
.isSignerTrusted(signerPubkey);
shouldShowDialog = !isTrusted;
} catch (_) {
shouldShowDialog = true;
}
}
if (shouldShowDialog) {
if (!context.mounted) return;
final result =
await showBaseDialog<({bool trustPermanently})>(
context: context,
dialog: InstallAlertDialog(app: app),
);
if (result == null) return;
if (result.trustPermanently && signerPubkey != null) {
try {
await ref
.read(trustServiceProvider)
.addTrustedSigner(signerPubkey);
} catch (_) {
// ignore persistence errors
}
}
}
}
try {
await onPressed();
} catch (e) {
// Store the error message for display
lastError = e.toString();
rethrow;
}
},
builder: (context, child, callback, buttonState) {
return _buildSimpleButton(
context,
buttonState.maybeWhen(
loading: () => text == 'Open' ? 'Launching...' : 'Starting...',
orElse: () => text,
),
buttonState.maybeWhen(loading: () => null, orElse: () => callback),
isPrimary: isPrimary,
showSpinner: false,
fontSize: fontSize,
);
},
child: Text(
text,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
onError: () {
if (context.mounted) {
// Show the actual error message if available
final message = lastError != null
? lastError!.replaceFirst('Exception: ', '')
: 'Operation failed. Please try again.';
context.showError(message);
}
},
);
}
Widget _buildUninstallIconButton(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return IconButton.filled(
onPressed: () => _uninstallApp(ref, context),
icon: const Icon(Icons.delete_outline),
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.errorContainer,
foregroundColor: theme.colorScheme.onErrorContainer,
padding: const EdgeInsets.all(12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
tooltip: 'Uninstall',
);
}
// Removed: update logic moved to AppExt
Widget _buildOpenIconButton(BuildContext context, WidgetRef ref) {
const actionColor = AppColors.darkActionPrimary;
return IconButton.filled(
onPressed: () => _openApp(context, ref),
icon: const Icon(Icons.open_in_new),
style: IconButton.styleFrom(
backgroundColor: actionColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
tooltip: 'Open',
);
}
Widget _buildProgressButton(
BuildContext context,
WidgetRef ref, {
required double progress,
required String text,
required double fontSize,
VoidCallback? onTap,
}) {
const actionColor = AppColors.darkActionPrimary;
final darkerAction = Color.alphaBlend(
Colors.black.withValues(alpha: 0.22),
actionColor,
);
return FilledButton(
onPressed: onTap ?? () => _cancelDownload(ref),
style: FilledButton.styleFrom(
backgroundColor: Colors.transparent,
disabledBackgroundColor: Colors.transparent,
disabledForegroundColor: Colors.white,
foregroundColor: Colors.white,
elevation: 0,
shadowColor: Colors.transparent,
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
children: [
Positioned.fill(child: Container(color: actionColor)),
Positioned.fill(
child: Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: progress,
heightFactor: 1.0,
child: Container(color: darkerAction),
),
),
),
Center(
child: Text(
text,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
],
),
),
);
}
Widget _buildSimpleButton(
BuildContext context,
String text,
VoidCallback? onPressed, {
bool isPrimary = true,
bool isError = false,
bool showSpinner = false,
double fontSize = 16.0,
bool isDowngrade = false,
}) {
final theme = Theme.of(context);
Widget child = Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(
text,
style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
if (showSpinner) ...[
const SizedBox(width: 8),
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
],
],
);
if (isError) {
return FilledButton(
onPressed: onPressed,
style: FilledButton.styleFrom(
backgroundColor: theme.colorScheme.error,
disabledBackgroundColor: theme.colorScheme.error,
disabledForegroundColor: Colors.white,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: child,
);
} else if (isDowngrade) {
// Greyed out disabled button for downgrades (opaque)
final greyColor = theme.colorScheme.outline;
return FilledButton(
onPressed: null, // disabled
style: FilledButton.styleFrom(
backgroundColor: greyColor,
disabledBackgroundColor: greyColor,
disabledForegroundColor: theme.colorScheme.onSurface.withValues(
alpha: 0.5,
),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: child,
);
} else {
const actionColor = AppColors.darkActionPrimary;
final backgroundColor = isPrimary
? actionColor
: actionColor.withValues(alpha: 0.8);
return FilledButton(
onPressed: onPressed,
style: FilledButton.styleFrom(
backgroundColor: backgroundColor,
disabledBackgroundColor: backgroundColor,
disabledForegroundColor: Colors.white,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: child,
);
}
}
Future<void> _startDownload(WidgetRef ref) async {
if (release == null) return;
final downloadService = ref.read(downloadServiceProvider.notifier);
await downloadService.downloadApp(app, release!);
}
Future<void> _installFromDownloaded(WidgetRef ref) async {
try {
final downloadService = ref.read(downloadServiceProvider.notifier);
await downloadService.installFromDownloaded(app.identifier);
} catch (_) {
rethrow;
}
}
Future<void> _resumeDownload(WidgetRef ref) async {
final downloadService = ref.read(downloadServiceProvider.notifier);
await downloadService.resumeDownload(app.identifier);
}
Future<void> _pauseDownload(WidgetRef ref) async {
try {
final downloadService = ref.read(downloadServiceProvider.notifier);
await downloadService.pauseDownload(app.identifier);
} catch (e) {
// Failed to pause download
}
}
Future<void> _cancelDownload(WidgetRef ref) async {
try {
final downloadService = ref.read(downloadServiceProvider.notifier);
await downloadService.cancelDownload(app.identifier);
} catch (e) {
// Failed to cancel download
}
}
Future<void> _openApp(BuildContext context, WidgetRef ref) async {
try {
final packageManager = ref.read(packageManagerProvider.notifier);
context.showInfo('Launching ${app.name ?? app.identifier}...');
await packageManager.launchApp(app.identifier);
} catch (e) {
if (!context.mounted) return;
context.showError('Failed to launch ${app.name ?? app.identifier}: $e');
}
}
Future<void> _cancelAndRestart(WidgetRef ref) async {
final downloadService = ref.read(downloadServiceProvider.notifier);
await downloadService.cancelDownload(app.identifier);
}
String? _formatTotalSizeMb(Release? release) {
try {
final sizeBytes = app.latestFileMetadata?.size;
if (sizeBytes == null || sizeBytes <= 0) return null;
final mb = sizeBytes / (1024 * 1024);
return '${mb.toStringAsFixed(1)} MB';
} catch (_) {
return null;
}
}
void _handleErrorTap(
WidgetRef ref,
BuildContext context,
DownloadInfo downloadInfo,
bool canRetryReckless,
) {
final errorMessage =
downloadInfo.errorDetails ?? 'Download failed. Please try again.';
context.showError(
errorMessage,
actions: canRetryReckless
? [
(
'⚠️ Proceed anyway (RECKLESS)',
() async {
// Retry installation with skipVerification=true
final filePath = await downloadInfo.task.filePath();
final packageManager = ref.read(
packageManagerProvider.notifier,
);
// Update download state to show installing
final downloadService = ref.read(
downloadServiceProvider.notifier,
);
downloadService.markInstalling(app.identifier);
try {
await packageManager.install(
app.identifier,
filePath,
expectedHash: downloadInfo.fileMetadata.hash,
expectedSize: downloadInfo.fileMetadata.size ?? 0,
skipVerification: true, // SKIP VERIFICATION
);
} catch (e) {
if (context.mounted) {
context.showError('Installation failed: $e');
}
}
},
),
]
: [],
);
// Only cancel if user doesn't have the option to proceed
// For hash errors, keep the download so user can proceed
if (!canRetryReckless) {
final downloadService = ref.read(downloadServiceProvider.notifier);
downloadService.cancelDownload(app.identifier);
}
}
Future<void> _uninstallApp(WidgetRef ref, BuildContext context) async {
try {
final packageManager = ref.read(packageManagerProvider.notifier);
await packageManager.uninstall(app.identifier);
// Only reaches here after successful uninstall
if (context.mounted) {
context.showInfo('${app.name ?? app.identifier} has been uninstalled');
}
} catch (e) {
if (context.mounted) {
// Don't show error for user cancellation
final message = e.toString();
if (!message.contains('cancelled')) {
context.showError('Uninstall failed: $e');
}
}
}
}
Future<void> _showForceUpdateDialog(
WidgetRef ref,
BuildContext context,
) async {
// Get version and certificate info
final installedPackage = ref
.read(packageManagerProvider)
.where((p) => p.appId == app.identifier)
.firstOrNull;
final updateVersion = app.latestFileMetadata?.version ?? 'Unknown';
final currentVersion = installedPackage?.version ?? 'Unknown';
final currentCertHash = installedPackage?.signatureHash ?? 'Unknown';
final updateCertHash =
app.latestFileMetadata?.apkSignatureHash ?? 'Unknown';
// Get author profile
final author = app.author.value;
final shouldProceed = await showBaseDialog<bool>(
context: context,
dialog: Builder(
builder: (dialogContext) => BaseDialog(
titleIcon: Icon(
Icons.security,
color: Theme.of(dialogContext).colorScheme.error,
),
title: const BaseDialogTitle('Certificate Mismatch'),
content: BaseDialogContent(
children: [
Row(
children: [
Expanded(
child: AuthorContainer(
profile: author!,
beforeText: 'This update was published by',
afterText: ' but signed with a different certificate.',
oneLine: false,
size: 14,
),
),
],
),
const SizedBox(height: 12),
// Compact version and certificate comparison
_buildCompactHashRow(
dialogContext,
'Current ($currentVersion)',
currentCertHash,
'Installed version certificate',
),
const SizedBox(height: 8),
_buildCompactHashRow(
dialogContext,
'Update ($updateVersion)',
updateCertHash,
'New version certificate',
),
const SizedBox(height: 12),
Text(
'Android security prevents updating apps signed by different certificates. Contact the publisher for details.',
style: TextStyle(fontSize: 14),
),
const SizedBox(height: 8),
Text(
'To proceed anyway, uninstall the current version and install the new one. ALL APP DATA WILL BE LOST.',
style: TextStyle(fontSize: 14),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
foregroundColor: Colors.white,
),
child: const Text('Uninstall & Install'),
),
],
),
),
);
if (shouldProceed == true && context.mounted) {
try {
final downloadService = ref.read(downloadServiceProvider.notifier);
final packageManager = ref.read(packageManagerProvider.notifier);
final downloadInfo = downloadService.getDownloadInfo(app.identifier);
if (downloadInfo == null) {
throw Exception('Download not found');
}
final filePath = await downloadInfo.task.filePath();
final fileMetadata = downloadInfo.fileMetadata;
downloadService.clearError(app.identifier);
await packageManager.uninstall(app.identifier);
downloadService.markInstalling(app.identifier);
await packageManager.install(
app.identifier,
filePath,
expectedHash: fileMetadata.hash,
expectedSize: fileMetadata.size ?? 0,
);
downloadService.cancelDownload(app.identifier);
} catch (e) {
final message = e.toString();
if (context.mounted && !message.contains('cancelled')) {
context.showError('Force update failed: $e');
}
}
}
}
String _abbr(String v) {
final t = v.trim();
if (t.length <= 12) return t;
return '${t.substring(0, 6)}...${t.substring(t.length - 6)}';
}
Widget _buildCompactHashRow(
BuildContext context,
String versionLabel,
String hash,
String tooltipText,
) {
return Row(
children: [
Expanded(
child: RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodyMedium,
children: [
TextSpan(
text: '$versionLabel → ',
style: TextStyle(fontWeight: FontWeight.w600),
),
TextSpan(
text: _abbr(hash),
style: TextStyle(fontFamily: 'monospace'),
),
],
),
),
),
IconButton(
icon: const Icon(Icons.copy, size: 16),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
style: IconButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: Size.zero,
),
tooltip: 'Copy certificate hash',
onPressed: () {
Clipboard.setData(ClipboardData(text: hash));
context.showInfo('Copied $tooltipText');
},
),
],
);
}
}