A more robust self hosted OTA updates implementation
This commit is contained in:
+164
-29
@@ -1,4 +1,8 @@
|
||||
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';
|
||||
@@ -16,9 +20,11 @@ class UpdateDialog extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _UpdateDialogState extends State<UpdateDialog> {
|
||||
double _progress = 0;
|
||||
double _realProgress = 0.0;
|
||||
bool _downloading = false;
|
||||
bool _failed = false;
|
||||
List<dynamic>? _notesDelta;
|
||||
String? _notesPlain;
|
||||
|
||||
Future<void> _startDownload() async {
|
||||
setState(() {
|
||||
@@ -29,7 +35,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
try {
|
||||
await AppUpdateService.instance.downloadAndInstallApk(
|
||||
widget.info.latestVersion!.downloadUrl,
|
||||
onProgress: (p) => setState(() => _progress = p),
|
||||
onProgress: (p) => setState(() => _realProgress = p),
|
||||
);
|
||||
// once the installer launches the app is likely to be stopped; we
|
||||
// don't pop the dialog explicitly.
|
||||
@@ -49,6 +55,20 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
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.
|
||||
@@ -65,19 +85,52 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (notes.isNotEmpty) ...[Text(notes), const SizedBox(height: 12)],
|
||||
if (_downloading)
|
||||
Column(
|
||||
children: [
|
||||
LinearProgressIndicator(value: _progress),
|
||||
const SizedBox(height: 8),
|
||||
Text('${(_progress * 100).toStringAsFixed(0)}%'),
|
||||
],
|
||||
// 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 Text(
|
||||
'An error occurred while downloading. Please try again.',
|
||||
style: TextStyle(color: Colors.red),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
'An error occurred while downloading. Please try again.',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -87,26 +140,108 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
}
|
||||
|
||||
List<Widget> _buildActions() {
|
||||
if (_downloading) {
|
||||
// don't show any actions while the apk is being fetched
|
||||
return <Widget>[];
|
||||
}
|
||||
final actions = <Widget>[];
|
||||
|
||||
if (widget.info.isForceUpdate) {
|
||||
return [
|
||||
FilledButton(
|
||||
onPressed: _startDownload,
|
||||
child: const Text('Update Now'),
|
||||
if (!widget.info.isForceUpdate && !_downloading) {
|
||||
actions.add(
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Later'),
|
||||
),
|
||||
];
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
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'),
|
||||
),
|
||||
FilledButton(onPressed: _startDownload, child: 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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user