OTA Updates for adnroid app and web apk uploader
This commit is contained in:
@@ -9,6 +9,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
||||
|
||||
import '../../services/ai_service.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import '../../widgets/gemini_animated_text_field.dart';
|
||||
|
||||
/// A simple admin-only web page allowing the upload of a new APK and the
|
||||
/// associated metadata. After the APK is uploaded to the "apk_updates"
|
||||
/// bucket the `app_versions` table is updated and any older rows are removed
|
||||
@@ -26,6 +30,11 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||
final _minController = TextEditingController();
|
||||
final _notesController = TextEditingController();
|
||||
quill.QuillController? _quillController;
|
||||
final FocusNode _quillFocusNode = FocusNode();
|
||||
final ScrollController _quillScrollController = ScrollController();
|
||||
bool _isImprovingNotes = false;
|
||||
String? _existingVersion;
|
||||
|
||||
// We store release notes as plain text for compatibility; existing
|
||||
// Quill delta JSON in the database will be parsed and displayed by
|
||||
// the Android update dialog renderer.
|
||||
@@ -44,13 +53,83 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Always have a controller so web edits do not reset when the widget rebuilds.
|
||||
_setQuillController(quill.QuillController.basic());
|
||||
_loadCurrent();
|
||||
}
|
||||
|
||||
void _setQuillController(quill.QuillController controller) {
|
||||
_quillController?.removeListener(_updateQuillToolbar);
|
||||
_quillController = controller;
|
||||
_quillController?.addListener(_updateQuillToolbar);
|
||||
}
|
||||
|
||||
void _updateQuillToolbar() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _toggleAttribute(quill.Attribute attribute) {
|
||||
final current = _quillController?.getSelectionStyle();
|
||||
if (current?.attributes.containsKey(attribute.key) == true) {
|
||||
_quillController?.formatSelection(quill.Attribute.clone(attribute, null));
|
||||
} else {
|
||||
_quillController?.formatSelection(attribute);
|
||||
}
|
||||
}
|
||||
|
||||
int? _currentHeaderLevel() {
|
||||
final attrs = _quillController?.getSelectionStyle().attributes;
|
||||
if (attrs == null) return null;
|
||||
final Object? headerValue = attrs[quill.Attribute.header.key];
|
||||
if (headerValue is quill.HeaderAttribute) {
|
||||
return headerValue.value;
|
||||
}
|
||||
if (headerValue is int) {
|
||||
return headerValue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _setHeader(int? level) {
|
||||
if (level == null) {
|
||||
_quillController?.formatSelection(
|
||||
quill.Attribute.clone(quill.Attribute.header, null),
|
||||
);
|
||||
} else {
|
||||
_quillController?.formatSelection(quill.HeaderAttribute(level: level));
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildToolbarIcon({
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
required bool isActive,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
return IconButton(
|
||||
tooltip: tooltip,
|
||||
icon: Icon(icon),
|
||||
color: isActive
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface.withAlpha((0.75 * 255).round()),
|
||||
onPressed: onPressed,
|
||||
splashRadius: 20,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadCurrent() async {
|
||||
try {
|
||||
final client = Supabase.instance.client;
|
||||
final rows = await client.from('app_versions').select().maybeSingle();
|
||||
|
||||
// We will update the UI once we have the values so the initial load
|
||||
// populates the form fields and editor reliably.
|
||||
String? existingVersion;
|
||||
String? versionText;
|
||||
String? minVersionText;
|
||||
quill.QuillController? quillController;
|
||||
|
||||
// when using text versions we can't rely on server-side ordering; instead
|
||||
// parse locally and choose the greatest semantic version.
|
||||
if (rows is List) {
|
||||
@@ -75,46 +154,78 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||
}
|
||||
}
|
||||
if (bestRow != null) {
|
||||
_versionController.text = bestRow['version_code']?.toString() ?? '';
|
||||
_minController.text =
|
||||
bestRow['min_version_required']?.toString() ?? '';
|
||||
existingVersion = bestRow['version_code']?.toString();
|
||||
versionText = bestRow['version_code']?.toString() ?? '';
|
||||
minVersionText = bestRow['min_version_required']?.toString() ?? '';
|
||||
final rn = bestRow['release_notes'] ?? '';
|
||||
if (rn is String && rn.trim().isNotEmpty) {
|
||||
try {
|
||||
final parsed = jsonDecode(rn);
|
||||
if (parsed is List) {
|
||||
final doc = quill.Document.fromJson(parsed);
|
||||
_quillController = quill.QuillController(
|
||||
quillController = quill.QuillController(
|
||||
document: doc,
|
||||
selection: const TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
} else {
|
||||
_notesController.text = rn.toString();
|
||||
final doc = quill.Document()..insert(0, rn.toString());
|
||||
quillController = quill.QuillController(
|
||||
document: doc,
|
||||
selection: const TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
_notesController.text = rn.toString();
|
||||
final doc = quill.Document()..insert(0, rn.toString());
|
||||
quillController = quill.QuillController(
|
||||
document: doc,
|
||||
selection: const TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (rows is Map<String, dynamic>) {
|
||||
_versionController.text = rows['version_code']?.toString() ?? '';
|
||||
_minController.text = rows['min_version_required']?.toString() ?? '';
|
||||
existingVersion = rows['version_code']?.toString();
|
||||
versionText = rows['version_code']?.toString() ?? '';
|
||||
minVersionText = rows['min_version_required']?.toString() ?? '';
|
||||
final rn = rows['release_notes'] ?? '';
|
||||
try {
|
||||
final parsed = jsonDecode(rn);
|
||||
if (parsed is List) {
|
||||
final doc = quill.Document.fromJson(parsed);
|
||||
_quillController = quill.QuillController(
|
||||
quillController = quill.QuillController(
|
||||
document: doc,
|
||||
selection: const TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
} else {
|
||||
_notesController.text = rn as String;
|
||||
final doc = quill.Document()..insert(0, rn.toString());
|
||||
quillController = quill.QuillController(
|
||||
document: doc,
|
||||
selection: const TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
_notesController.text = rn as String;
|
||||
final doc = quill.Document()..insert(0, rn.toString());
|
||||
quillController = quill.QuillController(
|
||||
document: doc,
|
||||
selection: const TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_existingVersion = existingVersion;
|
||||
if (versionText != null) {
|
||||
_versionController.text = versionText;
|
||||
}
|
||||
if (minVersionText != null) {
|
||||
_minController.text = minVersionText;
|
||||
}
|
||||
if (quillController != null) {
|
||||
_setQuillController(quillController);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -123,6 +234,8 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||
_versionController.dispose();
|
||||
_minController.dispose();
|
||||
_notesController.dispose();
|
||||
_quillFocusNode.dispose();
|
||||
_quillScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -139,6 +252,69 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _improveReleaseNotesWithGemini(BuildContext context) async {
|
||||
final controller = _quillController;
|
||||
if (controller == null) return;
|
||||
final plain = controller.document.toPlainText().trim();
|
||||
if (plain.isEmpty) {
|
||||
if (!context.mounted) return;
|
||||
showWarningSnackBar(context, 'Please enter some release notes first');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isImprovingNotes = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final aiService = AiService();
|
||||
final from = _existingVersion?.trim();
|
||||
final to = _versionController.text.trim();
|
||||
final prompt = StringBuffer(
|
||||
'Improve these release notes for a mobile app update. '
|
||||
'Keep the message concise, clear, and professional. '
|
||||
'The notes may include formatting (bold, italic, underline, headings, lists, code blocks, links). '
|
||||
'Do NOT change or remove existing markdown-style formatting markers. '
|
||||
'Return ONLY the improved release notes in plain text using markdown-style markers (e.g. **bold**, *italic*, `code`, # Heading, - list, [link](url)). '
|
||||
'Do not output HTML, JSON, or any extra commentary. '
|
||||
'If the input already contains markdown markers, keep them and do not rewrite them into a different format.',
|
||||
);
|
||||
if (from != null && from.isNotEmpty && to.isNotEmpty) {
|
||||
prompt.write(' Update is from $from → $to.');
|
||||
} else if (to.isNotEmpty) {
|
||||
prompt.write(' Update version: $to.');
|
||||
}
|
||||
|
||||
final improved = await aiService.enhanceText(
|
||||
plain,
|
||||
promptInstruction: prompt.toString(),
|
||||
);
|
||||
|
||||
final trimmed = improved.trim();
|
||||
final docLen = controller.document.length;
|
||||
controller.replaceText(
|
||||
0,
|
||||
docLen - 1,
|
||||
trimmed,
|
||||
TextSelection.collapsed(offset: trimmed.length),
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
showSuccessSnackBar(context, 'Release notes improved with Gemini');
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, 'Gemini failed: $e');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isImprovingNotes = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
if (_apkBytes == null || _apkName == null) {
|
||||
@@ -202,6 +378,8 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||
});
|
||||
});
|
||||
|
||||
// Upload can occasionally stall on web; enforce a timeout so the UI
|
||||
// shows an error instead of hanging at 95%.
|
||||
final uploadRes = await client.storage
|
||||
.from('apk_updates')
|
||||
.uploadBinary(
|
||||
@@ -211,6 +389,10 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||
upsert: true,
|
||||
contentType: 'application/vnd.android.package-archive',
|
||||
),
|
||||
)
|
||||
.timeout(
|
||||
const Duration(minutes: 3),
|
||||
onTimeout: () => throw Exception('Upload timed out. Please retry.'),
|
||||
);
|
||||
stopwatch.stop();
|
||||
_logs.add('Upload finished (took ${stopwatch.elapsed.inSeconds}s)');
|
||||
@@ -273,7 +455,11 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
_logs.add('Error during upload: $e');
|
||||
setState(() => _error = e.toString());
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_progress = null;
|
||||
_realProgress = 0;
|
||||
});
|
||||
} finally {
|
||||
_startDelayTimer?.cancel();
|
||||
_startDelayTimer = null;
|
||||
@@ -328,16 +514,252 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||
validator: (v) =>
|
||||
(v == null || v.isEmpty) ? 'Required' : null,
|
||||
),
|
||||
// Release notes: use Quill rich editor when available (web)
|
||||
// Release notes: use Quill rich editor when available (web).
|
||||
if (_quillController != null || kIsWeb) ...[
|
||||
// toolbar omitted (package version may not export it)
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: quill.QuillEditor.basic(
|
||||
controller:
|
||||
_quillController ??
|
||||
quill.QuillController.basic(),
|
||||
GeminiAnimatedBorder(
|
||||
isProcessing: _isImprovingNotes,
|
||||
borderRadius: 12,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Quill toolbar (basic formatting + headings/lists/links/code)
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_buildToolbarIcon(
|
||||
icon: Icons.format_bold,
|
||||
tooltip: 'Bold',
|
||||
isActive:
|
||||
_quillController
|
||||
?.getSelectionStyle()
|
||||
.attributes
|
||||
.containsKey(
|
||||
quill.Attribute.bold.key,
|
||||
) ==
|
||||
true,
|
||||
onPressed: () {
|
||||
_toggleAttribute(quill.Attribute.bold);
|
||||
},
|
||||
),
|
||||
_buildToolbarIcon(
|
||||
icon: Icons.format_italic,
|
||||
tooltip: 'Italic',
|
||||
isActive:
|
||||
_quillController
|
||||
?.getSelectionStyle()
|
||||
.attributes
|
||||
.containsKey(
|
||||
quill.Attribute.italic.key,
|
||||
) ==
|
||||
true,
|
||||
onPressed: () {
|
||||
_toggleAttribute(
|
||||
quill.Attribute.italic,
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildToolbarIcon(
|
||||
icon: Icons.format_underline,
|
||||
tooltip: 'Underline',
|
||||
isActive:
|
||||
_quillController
|
||||
?.getSelectionStyle()
|
||||
.attributes
|
||||
.containsKey(
|
||||
quill.Attribute.underline.key,
|
||||
) ==
|
||||
true,
|
||||
onPressed: () {
|
||||
_toggleAttribute(
|
||||
quill.Attribute.underline,
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildToolbarIcon(
|
||||
icon: Icons.format_list_bulleted,
|
||||
tooltip: 'Bullet list',
|
||||
isActive:
|
||||
_quillController
|
||||
?.getSelectionStyle()
|
||||
.attributes
|
||||
.containsKey(
|
||||
quill.Attribute.ul.key,
|
||||
) ==
|
||||
true,
|
||||
onPressed: () {
|
||||
_toggleAttribute(quill.Attribute.ul);
|
||||
},
|
||||
),
|
||||
_buildToolbarIcon(
|
||||
icon: Icons.code,
|
||||
tooltip: 'Code block',
|
||||
isActive:
|
||||
_quillController
|
||||
?.getSelectionStyle()
|
||||
.attributes
|
||||
.containsKey(
|
||||
quill.Attribute.codeBlock.key,
|
||||
) ==
|
||||
true,
|
||||
onPressed: () {
|
||||
_toggleAttribute(
|
||||
quill.Attribute.codeBlock,
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildToolbarIcon(
|
||||
icon: Icons.format_size,
|
||||
tooltip: 'Heading 1',
|
||||
isActive: _currentHeaderLevel() == 1,
|
||||
onPressed: () {
|
||||
_setHeader(1);
|
||||
},
|
||||
),
|
||||
_buildToolbarIcon(
|
||||
icon: Icons.format_size,
|
||||
tooltip: 'Heading 2',
|
||||
isActive: _currentHeaderLevel() == 2,
|
||||
onPressed: () {
|
||||
_setHeader(2);
|
||||
},
|
||||
),
|
||||
_buildToolbarIcon(
|
||||
icon: Icons.format_size,
|
||||
tooltip: 'Heading 3',
|
||||
isActive: _currentHeaderLevel() == 3,
|
||||
onPressed: () {
|
||||
_setHeader(3);
|
||||
},
|
||||
),
|
||||
_buildToolbarIcon(
|
||||
icon: Icons.link,
|
||||
tooltip: 'Link',
|
||||
isActive:
|
||||
_quillController
|
||||
?.getSelectionStyle()
|
||||
.attributes
|
||||
.containsKey(
|
||||
quill.Attribute.link.key,
|
||||
) ==
|
||||
true,
|
||||
onPressed: () async {
|
||||
final selection =
|
||||
_quillController!.selection;
|
||||
if (selection.isCollapsed) return;
|
||||
final current = _quillController!
|
||||
.getSelectionStyle();
|
||||
final existing =
|
||||
current.attributes[quill
|
||||
.Attribute
|
||||
.link
|
||||
.key];
|
||||
final url = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final ctrl = TextEditingController(
|
||||
text: existing?.value as String?,
|
||||
);
|
||||
return AlertDialog(
|
||||
title: const Text('Insert link'),
|
||||
content: TextField(
|
||||
controller: ctrl,
|
||||
decoration:
|
||||
const InputDecoration(
|
||||
labelText: 'URL',
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(
|
||||
context,
|
||||
).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).pop(ctrl.text.trim());
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (url == null) return;
|
||||
if (url.isEmpty) {
|
||||
_toggleAttribute(
|
||||
quill.Attribute.link,
|
||||
);
|
||||
} else {
|
||||
_quillController!.formatSelection(
|
||||
quill.LinkAttribute(url),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: quill.QuillEditor.basic(
|
||||
controller: _quillController!,
|
||||
focusNode: _quillFocusNode,
|
||||
scrollController: _quillScrollController,
|
||||
config: quill.QuillEditorConfig(
|
||||
scrollable: true,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: _isImprovingNotes
|
||||
? null
|
||||
: () =>
|
||||
_improveReleaseNotesWithGemini(
|
||||
context,
|
||||
),
|
||||
icon: _isImprovingNotes
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome),
|
||||
label: const Text('Improve'),
|
||||
),
|
||||
if (_isImprovingNotes) ...[
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Improving...',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
|
||||
Reference in New Issue
Block a user