import 'package:flutter/material.dart'; import '../l10n/app_localizations.dart'; import '../l10n/app_localizations_helpers.dart'; import '../utils/app_update_util.dart'; import '../utils/navigation.dart'; import '../viewmodels/update_progress_view_model.dart'; class UpdateProgressView extends StatefulWidget { final UpdateInfo update; final bool simulate; final bool simulateError; const UpdateProgressView({ super.key, required this.update, this.simulate = false, this.simulateError = false, }); @override State createState() => _UpdateProgressViewState(); } class _UpdateProgressViewState extends State { late final UpdateProgressViewModel _vm; @override void initState() { super.initState(); _vm = UpdateProgressViewModel( update: widget.update, simulate: widget.simulate, simulateError: widget.simulateError, )..init(); } @override void dispose() { _vm.dispose(); super.dispose(); } void _close() { if (_vm.isBusy) { _showCancelDialog(); return; } _vm.cancel(); context.popOrGo('/bar'); } @override Widget build(BuildContext context) { return PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) { if (!didPop) _close(); }, child: Scaffold( appBar: AppBar( leading: IconButton(icon: const Icon(Icons.close), onPressed: _close), automaticallyImplyLeading: false, ), body: ListenableBuilder( listenable: _vm, builder: (context, child) { return SafeArea( child: Center( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(24, 0, 24, 32), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _Header(vm: _vm), const SizedBox(height: 32), AnimatedSwitcher( duration: const Duration(milliseconds: 250), child: switch (_vm.phase) { UpdatePhase.downloading || UpdatePhase.installing => _Progress(vm: _vm), UpdatePhase.waitingForInstaller => _WaitingForInstaller(onRetry: _vm.start), UpdatePhase.failed => _Failed( vm: _vm, onClose: _close, ), }, ), if (widget.update.notes.trim().isNotEmpty) ...[ const SizedBox(height: 32), _ReleaseNotes(notes: widget.update.notes.trim()), ], if (_vm.isBusy) ...[ const SizedBox(height: 24), const _DoNotCloseHint(), ], ], ), ), ), ), ); }, ), ), ); } void _showCancelDialog() { final l10n = AppLocalizations.of(context); showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(l10n.cancelUpdate), content: Text(l10n.cancelUpdateDescription), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: Text(l10n.continueUpdate), ), FilledButton( style: FilledButton.styleFrom( backgroundColor: Theme.of(dialogContext).colorScheme.error, ), onPressed: () { Navigator.pop(dialogContext); _vm.cancel(); context.popOrGo('/bar'); }, child: Text(l10n.cancelUpdateAction), ), ], ), ); } } class _Header extends StatelessWidget { final UpdateProgressViewModel vm; const _Header({required this.vm}); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final scheme = theme.colorScheme; final (icon, background, foreground) = switch (vm.phase) { UpdatePhase.downloading => ( Icons.download_rounded, scheme.primaryContainer, scheme.onPrimaryContainer, ), UpdatePhase.installing => ( Icons.install_mobile_rounded, scheme.primaryContainer, scheme.onPrimaryContainer, ), UpdatePhase.waitingForInstaller => ( Icons.touch_app_rounded, scheme.tertiaryContainer, scheme.onTertiaryContainer, ), UpdatePhase.failed => ( Icons.error_outline_rounded, scheme.errorContainer, scheme.onErrorContainer, ), }; return Column( children: [ AnimatedContainer( duration: const Duration(milliseconds: 250), width: 88, height: 88, decoration: BoxDecoration( color: background, borderRadius: BorderRadius.circular(28), ), child: AnimatedSwitcher( duration: const Duration(milliseconds: 250), child: Icon(icon, key: ValueKey(icon), size: 44, color: foreground), ), ), const SizedBox(height: 24), Text( l10n.updatingTo(vm.update.version), style: theme.textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w600, ), textAlign: TextAlign.center, ), if (vm.currentVersion case final current?) ...[ const SizedBox(height: 12), _VersionChange(from: current, to: vm.update.version), ], ], ); } } class _VersionChange extends StatelessWidget { final String from; final String to; const _VersionChange({required this.from, required this.to}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final scheme = theme.colorScheme; final style = theme.textTheme.labelLarge?.copyWith( fontFeatures: const [FontFeature.tabularFigures()], ); return Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), decoration: BoxDecoration( color: scheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(999), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text(from, style: style?.copyWith(color: scheme.onSurfaceVariant)), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Icon( Icons.arrow_forward_rounded, size: 16, color: scheme.onSurfaceVariant, ), ), Text(to, style: style?.copyWith(color: scheme.primary)), ], ), ), ); } } class _Progress extends StatelessWidget { final UpdateProgressViewModel vm; const _Progress({required this.vm}); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final scheme = theme.colorScheme; final installing = vm.phase == UpdatePhase.installing; final progress = vm.progress; final status = switch ((installing, progress)) { (true, _) => l10n.installingUpdate, (false, null) => l10n.preparingDownload, (false, _) => l10n.downloadingUpdate, }; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ Expanded( child: Text( status, style: theme.textTheme.titleMedium?.copyWith( color: scheme.onSurfaceVariant, ), ), ), if (!installing) Text( '${((progress ?? 0) * 100).round()}%', style: theme.textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.w600, fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ), const SizedBox(height: 12), TweenAnimationBuilder( tween: Tween(end: progress ?? 0), duration: const Duration(milliseconds: 300), curve: Curves.easeOut, builder: (context, value, child) => LinearProgressIndicator( value: installing || progress == null ? null : value, minHeight: 12, borderRadius: BorderRadius.circular(999), backgroundColor: scheme.surfaceContainerHighest, ), ), ], ); } } class _WaitingForInstaller extends StatelessWidget { final VoidCallback onRetry; const _WaitingForInstaller({required this.onRetry}); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); return _Panel( color: theme.colorScheme.tertiaryContainer, foreground: theme.colorScheme.onTertiaryContainer, title: l10n.confirmInstallation, message: l10n.confirmInstallationDescription, actions: [ OutlinedButton.icon( onPressed: onRetry, icon: const Icon(Icons.refresh_rounded), label: Text(l10n.openInstallerAgain), ), ], ); } } class _Failed extends StatelessWidget { final UpdateProgressViewModel vm; final VoidCallback onClose; const _Failed({required this.vm, required this.onClose}); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final scheme = Theme.of(context).colorScheme; return _Panel( color: scheme.errorContainer, foreground: scheme.onErrorContainer, title: l10n.updateFailed, message: l10n.updateFailureText(vm.failure), actions: [ TextButton(onPressed: onClose, child: Text(l10n.close)), FilledButton.icon( onPressed: vm.start, icon: const Icon(Icons.refresh_rounded), label: Text(l10n.retry), ), ], ); } } class _Panel extends StatelessWidget { final Color color; final Color foreground; final String title; final String message; final List actions; const _Panel({ required this.color, required this.foreground, required this.title, required this.message, required this.actions, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(20), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: theme.textTheme.titleMedium?.copyWith( color: foreground, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 6), Text( message, style: theme.textTheme.bodyMedium?.copyWith(color: foreground), ), const SizedBox(height: 16), Wrap( alignment: WrapAlignment.end, spacing: 8, runSpacing: 8, children: actions, ), ], ), ); } } class _ReleaseNotes extends StatelessWidget { final String notes; const _ReleaseNotes({required this.notes}); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.whatsNew, style: theme.textTheme.titleSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 8), Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(16), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: Text(notes, style: theme.textTheme.bodyMedium), ), ], ); } } class _DoNotCloseHint extends StatelessWidget { const _DoNotCloseHint(); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final color = theme.colorScheme.onSurfaceVariant; return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.info_outline_rounded, size: 16, color: color), const SizedBox(width: 6), Flexible( child: Text( l10n.doNotCloseDuringUpdate, style: theme.textTheme.bodySmall?.copyWith(color: color), ), ), ], ); } }