import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../theme/theme_extension.dart'; import 'client_update_provider.dart'; import 'update_info.dart'; /// Soft banner mounted at the top of the shell. Renders nothing when /// no update is available or the user has dismissed this version's /// banner. Tapping Install downloads the APK and fires the system /// install intent (Android only). class UpdateBanner extends ConsumerStatefulWidget { const UpdateBanner({super.key}); @override ConsumerState createState() => _UpdateBannerState(); } enum _Stage { idle, downloading, error } class _UpdateBannerState extends ConsumerState { _Stage _stage = _Stage.idle; double _progress = 0; String? _error; Future _onInstall(UpdateInfo info) async { setState(() { _stage = _Stage.downloading; _progress = 0; _error = null; }); try { final installer = await ref.read(updateInstallerProvider.future); final path = await installer.download( info.apkUrl, onProgress: (p) => setState(() => _progress = p), ); await installer.install(path); // Stage stays 'downloading' — Android system install dialog has // taken over. If user cancels, the banner is still here. } catch (e) { if (!mounted) return; setState(() { _stage = _Stage.error; _error = '$e'; }); } } void _onDismiss(UpdateInfo info) { ref.read(dismissUpdateProvider)(info.version); } @override Widget build(BuildContext context) { final info = ref.watch(shouldShowUpdateBannerProvider); if (info == null) return const SizedBox.shrink(); final fs = Theme.of(context).extension()!; return Material( color: fs.iron, child: SafeArea( bottom: false, child: Padding( padding: const EdgeInsets.fromLTRB(12, 4, 4, 4), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Icon(LucideIcons.download, color: fs.parchment, size: 16), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( _stage == _Stage.error ? 'Update failed' : 'Update Minstrel · ${info.version} available', style: TextStyle(color: fs.parchment, fontSize: 12), overflow: TextOverflow.ellipsis, ), if (_stage == _Stage.downloading) ...[ const SizedBox(height: 2), LinearProgressIndicator( value: _progress > 0 ? _progress : null, minHeight: 2, backgroundColor: fs.obsidian, valueColor: AlwaysStoppedAnimation(fs.accent), ), ], if (_stage == _Stage.error && _error != null) ...[ const SizedBox(height: 1), Text( _error!, style: TextStyle(color: fs.ash, fontSize: 11), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ], ), ), if (_stage == _Stage.idle) TextButton( onPressed: () => _onInstall(info), style: TextButton.styleFrom( foregroundColor: fs.accent, minimumSize: const Size(56, 28), padding: const EdgeInsets.symmetric(horizontal: 8), tapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, ), child: const Text('Install', style: TextStyle(fontSize: 13)), ), if (_stage == _Stage.error) TextButton( onPressed: () => _onInstall(info), style: TextButton.styleFrom( foregroundColor: fs.accent, minimumSize: const Size(56, 28), padding: const EdgeInsets.symmetric(horizontal: 8), tapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, ), child: const Text('Retry', style: TextStyle(fontSize: 13)), ), SizedBox( width: 32, height: 32, child: IconButton( tooltip: 'Dismiss', padding: EdgeInsets.zero, iconSize: 16, icon: Icon(LucideIcons.x, color: fs.ash), onPressed: () => _onDismiss(info), ), ), ], ), ), ), ); } }