248 lines
7.5 KiB
Dart
248 lines
7.5 KiB
Dart
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<UpdateDialog> createState() => _UpdateDialogState();
|
|
}
|
|
|
|
class _UpdateDialogState extends State<UpdateDialog> {
|
|
double _realProgress = 0.0;
|
|
bool _downloading = false;
|
|
bool _failed = false;
|
|
List<dynamic>? _notesDelta;
|
|
String? _notesPlain;
|
|
|
|
Future<void> _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<double>(
|
|
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<Widget> _buildActions() {
|
|
final actions = <Widget>[];
|
|
|
|
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<dynamic> delta, TextStyle? baseStyle) {
|
|
final children = <TextSpan>[];
|
|
|
|
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();
|
|
}
|
|
}
|