tasq/lib/widgets/update_dialog.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();
}
}