tasq/lib/widgets/update_dialog.dart

229 lines
7.3 KiB
Dart

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<UpdateDialog> createState() => _UpdateDialogState();
}
class _UpdateDialogState extends State<UpdateDialog> {
double _realProgress = 0.0;
bool _downloading = false;
bool _failed = false;
List<dynamic>? _notesDelta;
String? _notesPlain;
quill.QuillController? _notesController;
final FocusNode _notesFocusNode = FocusNode();
final ScrollController _notesScrollController = ScrollController();
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;
_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<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;
}
@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();
}