import 'dart:convert'; import 'package:flutter/material.dart'; // We render Quill deltas here without depending on the flutter_quill editor // API to avoid analyzer/API mismatches; we support common inline styles. 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; 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; } 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: [ // Render release notes: prefer Quill delta if available if (_notesDelta != null) SizedBox( height: 250, child: SingleChildScrollView( child: RichText( text: _deltaToTextSpan( _notesDelta!, Theme.of(context).textTheme.bodyMedium, ), ), ), ), if (_notesDelta == 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) const Padding( padding: EdgeInsets.only(top: 8.0), child: Text( 'An error occurred while downloading. Please try again.', style: TextStyle(color: Colors.red), ), ), ], ), 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; } TextSpan _deltaToTextSpan(List delta, TextStyle? baseStyle) { final children = []; TextStyle styleFromAttributes(Map? attrs) { var s = baseStyle ?? const TextStyle(); if (attrs == null) return s; if (attrs['header'] != null) { final level = attrs['header'] is int ? attrs['header'] as int : 1; s = s.copyWith( fontSize: 18.0 - (level - 1) * 2, fontWeight: FontWeight.bold, ); } if (attrs['bold'] == true) s = s.copyWith(fontWeight: FontWeight.bold); if (attrs['italic'] == true) s = s.copyWith(fontStyle: FontStyle.italic); if (attrs['underline'] == true) { s = s.copyWith(decoration: TextDecoration.underline); } if (attrs['strike'] == true) { s = s.copyWith(decoration: TextDecoration.lineThrough); } if (attrs['color'] is String) { try { final col = attrs['color'] as String; // simple support for hex colors like #rrggbb if (col.startsWith('#') && (col.length == 7 || col.length == 9)) { final hex = col.replaceFirst('#', ''); final value = int.parse(hex, radix: 16); s = s.copyWith( color: Color((hex.length == 6 ? 0xFF000000 : 0) | value), ); } } catch (_) {} } return s; } for (final op in delta) { if (op is Map && op.containsKey('insert')) { final insert = op['insert']; final attrs = op['attributes'] as Map?; String text; if (insert is String) { text = insert; } else if (insert is Map && insert.containsKey('image')) { // render image as alt text placeholder text = '[image]'; } else { text = insert.toString(); } children.add(TextSpan(text: text, style: styleFromAttributes(attrs))); } } return TextSpan(children: children, style: baseStyle); } @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; } else { _notesPlain = notes; } } catch (_) { _notesPlain = notes; } } } @override void dispose() { super.dispose(); } }