import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; import '../models/app_version.dart'; import '../services/app_update_service.dart'; /// A reusable dialog that can render both flexible and forced updates and /// report download progress. Callers should wrap this with `showDialog` and /// control ``barrierDismissible`` according to ``info.isForceUpdate``. class UpdateDialog extends StatefulWidget { final AppUpdateInfo info; const UpdateDialog({required this.info, super.key}); @override State createState() => _UpdateDialogState(); } class _UpdateDialogState extends State { double _realProgress = 0.0; bool _downloading = false; bool _failed = false; List? _notesDelta; String? _notesPlain; quill.QuillController? _notesController; final FocusNode _notesFocusNode = FocusNode(); final ScrollController _notesScrollController = ScrollController(); Future _startDownload() async { setState(() { _downloading = true; _failed = false; }); try { await AppUpdateService.instance.downloadAndInstallApk( widget.info.latestVersion!.downloadUrl, onProgress: (p) => setState(() => _realProgress = p), ); // once the installer launches the app is likely to be stopped; we // don't pop the dialog explicitly. } catch (err) { if (!mounted) return; setState(() { _failed = true; _downloading = false; }); ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Download failed: $err'))); } } @override Widget build(BuildContext context) { final notes = widget.info.latestVersion?.releaseNotes ?? ''; // parse release notes into a Quill delta list if possible if ((_notesDelta == null && _notesPlain == null) && notes.isNotEmpty) { try { final parsed = jsonDecode(notes); if (parsed is List) { _notesDelta = parsed; _notesController = quill.QuillController( document: quill.Document.fromJson(parsed), selection: const TextSelection.collapsed(offset: 0), readOnly: true, ); // Prevent keyboard focus while still allowing text selection + copy. _notesFocusNode.canRequestFocus = false; } else { _notesPlain = notes; } } catch (_) { _notesPlain = notes; } } // WillPopScope is deprecated in newer Flutter versions but PopScope // has a different API; to avoid breaking changes we continue to use the // old widget and suppress the warning. // ignore: deprecated_member_use return WillPopScope( onWillPop: () async { // prevent the user from dismissing when download is in progress or // when the dialog is forcing an update return !widget.info.isForceUpdate && !_downloading; }, child: AlertDialog( title: const Text('Update Available'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Show current + new versions if (widget.info.latestVersion != null) Padding( padding: const EdgeInsets.only(bottom: 12), child: Text( 'Version: ${widget.info.currentBuildNumber} → ${widget.info.latestVersion!.versionCode}', style: Theme.of(context).textTheme.bodyMedium, ), ), // Render release notes: prefer Quill delta if available if (_notesController != null) SizedBox( height: 250, child: quill.QuillEditor.basic( controller: _notesController!, focusNode: _notesFocusNode, scrollController: _notesScrollController, config: const quill.QuillEditorConfig( // Keep the editor readable/copyable but avoid showing the // selection toolbar or receiving keyboard focus. autoFocus: false, showCursor: false, enableInteractiveSelection: true, enableSelectionToolbar: false, // Prevent the context menu / selection toolbar from showing. contextMenuBuilder: _noContextMenu, ), ), ), if (_notesController == null && _notesPlain != null && _notesPlain!.isNotEmpty) SizedBox( height: 250, child: SingleChildScrollView( child: SelectableText(_notesPlain!), ), ), const SizedBox(height: 12), if (_downloading) ...[ SizedBox( width: double.infinity, child: const LinearProgressIndicator(value: null), ), const SizedBox(height: 8), TweenAnimationBuilder( tween: Tween(begin: 0.0, end: _realProgress), duration: const Duration(milliseconds: 300), builder: (context, value, child) { return Text( '${(value * 100).toStringAsFixed(value * 100 >= 10 ? 0 : 1)}%', ); }, ), ], if (_failed) Padding( padding: const EdgeInsets.only(top: 8.0), child: Text( 'An error occurred while downloading. Please try again.', style: TextStyle( color: Theme.of(context).colorScheme.error, ), ), ), ], ), actions: _buildActions(), ), ); } List _buildActions() { final actions = []; if (!widget.info.isForceUpdate && !_downloading) { actions.add( TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Later'), ), ); } actions.add( FilledButton( onPressed: _downloading ? null : _startDownload, child: _downloading ? const CircularProgressIndicator() : const Text('Update Now'), ), ); return actions; } @override void initState() { super.initState(); // Pre-parse release notes so heavy JSON parsing doesn't block UI later final notes = widget.info.latestVersion?.releaseNotes ?? ''; if (notes.isNotEmpty) { try { final parsed = jsonDecode(notes); if (parsed is List) { _notesDelta = parsed; _notesController = quill.QuillController( document: quill.Document.fromJson(parsed), selection: const TextSelection.collapsed(offset: 0), readOnly: true, ); // Prevent keyboard focus while still allowing text selection + copy. _notesFocusNode.canRequestFocus = false; } else { _notesPlain = notes; } } catch (_) { _notesPlain = notes; } } } @override void dispose() { _notesFocusNode.dispose(); _notesScrollController.dispose(); super.dispose(); } static Widget _noContextMenu(BuildContext context, Object state) => const SizedBox.shrink(); }