OTA Updates for adnroid app and web apk uploader

This commit is contained in:
2026-03-15 19:24:34 +08:00
parent 9bbaf67fef
commit 6fd3b66251
12 changed files with 1193 additions and 151 deletions
+50 -69
View File
@@ -1,8 +1,7 @@
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 'package:flutter_quill/flutter_quill.dart' as quill;
import '../models/app_version.dart';
import '../services/app_update_service.dart';
@@ -25,6 +24,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
bool _failed = false;
List<dynamic>? _notesDelta;
String? _notesPlain;
quill.QuillController? _notesController;
final FocusNode _notesFocusNode = FocusNode();
final ScrollController _notesScrollController = ScrollController();
Future<void> _startDownload() async {
setState(() {
@@ -56,11 +58,19 @@ class _UpdateDialogState extends State<UpdateDialog> {
final notes = widget.info.latestVersion?.releaseNotes ?? '';
// parse release notes into a Quill delta list if possible
if (_notesDelta == null && _notesPlain == null && notes.isNotEmpty) {
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;
}
@@ -85,20 +95,36 @@ class _UpdateDialogState extends State<UpdateDialog> {
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 (_notesDelta != null)
if (_notesController != null)
SizedBox(
height: 250,
child: SingleChildScrollView(
child: RichText(
text: _deltaToTextSpan(
_notesDelta!,
Theme.of(context).textTheme.bodyMedium,
),
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 (_notesDelta == null &&
if (_notesController == null &&
_notesPlain != null &&
_notesPlain!.isNotEmpty)
SizedBox(
@@ -163,64 +189,6 @@ class _UpdateDialogState extends State<UpdateDialog> {
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();
@@ -231,6 +199,14 @@ class _UpdateDialogState extends State<UpdateDialog> {
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;
}
@@ -242,6 +218,11 @@ class _UpdateDialogState extends State<UpdateDialog> {
@override
void dispose() {
_notesFocusNode.dispose();
_notesScrollController.dispose();
super.dispose();
}
static Widget _noContextMenu(BuildContext context, Object state) =>
const SizedBox.shrink();
}