229 lines
7.3 KiB
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();
|
|
}
|