tasq/lib/screens/admin/app_update_screen.dart

861 lines
36 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'package:pub_semver/pub_semver.dart';
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
/// so that only the current entry remains.
class AppUpdateScreen extends ConsumerStatefulWidget {
const AppUpdateScreen({super.key});
@override
ConsumerState<AppUpdateScreen> createState() => _AppUpdateScreenState();
}
class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
final _formKey = GlobalKey<FormState>();
final _versionController = TextEditingController();
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.
Uint8List? _apkBytes;
String? _apkName;
bool _isUploading = false;
double? _progress; // null => indeterminate, otherwise 0..1
double _realProgress = 0.0; // actual numeric progress for display (0..1)
String? _eta;
final List<String> _logs = [];
Timer? _progressTimer;
Timer? _startDelayTimer;
String? _error;
@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) {
final rowList = rows as List?;
Version? best;
Map<String, dynamic>? bestRow;
if (rowList != null) {
for (final r in rowList) {
if (r is Map<String, dynamic>) {
final v = r['version_code']?.toString() ?? '';
Version parsed;
try {
parsed = Version.parse(v);
} catch (_) {
continue;
}
if (best == null || parsed > best) {
best = parsed;
bestRow = r;
}
}
}
}
if (bestRow != null) {
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(
document: doc,
selection: const TextSelection.collapsed(offset: 0),
);
} else {
final doc = quill.Document()..insert(0, rn.toString());
quillController = quill.QuillController(
document: doc,
selection: const TextSelection.collapsed(offset: 0),
);
}
} catch (_) {
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>) {
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(
document: doc,
selection: const TextSelection.collapsed(offset: 0),
);
} else {
final doc = quill.Document()..insert(0, rn.toString());
quillController = quill.QuillController(
document: doc,
selection: const TextSelection.collapsed(offset: 0),
);
}
} catch (_) {
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 (_) {}
}
@override
void dispose() {
_versionController.dispose();
_minController.dispose();
_notesController.dispose();
_quillFocusNode.dispose();
_quillScrollController.dispose();
super.dispose();
}
Future<void> _pickApk() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['apk'],
);
if (result != null && result.files.single.bytes != null) {
setState(() {
_apkBytes = result.files.single.bytes;
_apkName = result.files.single.name;
});
}
}
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) {
setState(() => _error = 'Please select an APK file.');
return;
}
final vcode = _versionController.text.trim();
final minReq = _minController.text.trim();
String notes;
if (_quillController != null) {
notes = jsonEncode(_quillController!.document.toDelta().toJson());
} else {
notes = _notesController.text;
}
setState(() {
_isUploading = true;
_progress = null; // show indeterminate while we attempt to start
_eta = null;
_logs.clear();
_error = null;
});
try {
final client = Supabase.instance.client;
// ensure the user is authenticated (browser uploads require correct auth/CORS)
final user = client.auth.currentUser;
_logs.add('Current user: ${user?.id ?? 'anonymous'}');
if (user == null) {
throw Exception('Not signed in. Please sign in to perform uploads.');
}
// Use a deterministic object name to avoid accidental nesting
final filename = '${DateTime.now().millisecondsSinceEpoch}_$_apkName';
final path = filename;
_logs.add('Starting upload to bucket apk_updates, path: $path');
final stopwatch = Stopwatch()..start();
// Show an indeterminate bar briefly while the upload is negotiating;
// after a short delay, switch to a determinate (fake) progress based on
// the payload size so the UI feels responsive.
final estimatedSeconds = (_apkBytes!.length / 250000).clamp(1, 30);
_startDelayTimer?.cancel();
_startDelayTimer = Timer(const Duration(milliseconds: 700), () {
// keep the bar indeterminate, but start updating the numeric progress
setState(() {
_progress = null;
_realProgress = 0.0;
});
_progressTimer = Timer.periodic(const Duration(milliseconds: 200), (t) {
final elapsed = stopwatch.elapsed.inMilliseconds / 1000.0;
final pct = (elapsed / estimatedSeconds).clamp(0.0, 0.95);
setState(() {
_realProgress = pct;
final remaining = (estimatedSeconds - elapsed).clamp(
0.0,
double.infinity,
);
_eta = '${remaining.toStringAsFixed(1)}s';
});
});
});
// 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(
path,
_apkBytes!,
fileOptions: const FileOptions(
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)');
_logs.add('Raw upload response: ${uploadRes.runtimeType} - $uploadRes');
if (uploadRes is Map) {
final Map m = uploadRes as Map;
if (m.containsKey('error') && m['error'] != null) {
throw Exception('upload failed: ${m['error']}');
}
}
setState(() {
_realProgress = 0.95;
});
// retrieve public URL; various SDK versions return different structures
dynamic urlRes = client.storage.from('apk_updates').getPublicUrl(path);
_logs.add('Raw getPublicUrl response: ${urlRes.runtimeType} - $urlRes');
String url;
if (urlRes is String) {
url = urlRes;
} else if (urlRes is Map) {
// supabase responses vary by SDK version
if (urlRes['publicUrl'] is String) {
url = urlRes['publicUrl'] as String;
} else if (urlRes['data'] is String) {
url = urlRes['data'] as String;
} else if (urlRes['data'] is Map) {
final d = urlRes['data'] as Map;
url =
(d['publicUrl'] ?? d['public_url'] ?? d['url'] ?? d['publicURL'])
as String? ??
'';
} else {
url = '';
}
} else {
url = '';
}
if (url.isEmpty) {
throw Exception(
'could not obtain public url, check bucket CORS and policies',
);
}
_logs.add('Public URL: $url');
// upsert new version in a single statement
await client.from('app_versions').upsert({
'version_code': vcode,
'min_version_required': minReq,
'download_url': url,
'release_notes': notes,
}, onConflict: 'version_code');
await client.from('app_versions').delete().neq('version_code', vcode);
setState(() {
_realProgress = 1.0;
_progress = 1.0;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Version saved successfully')),
);
}
} catch (e) {
_logs.add('Error during upload: $e');
setState(() {
_error = e.toString();
_progress = null;
_realProgress = 0;
});
} finally {
_startDelayTimer?.cancel();
_startDelayTimer = null;
_progressTimer?.cancel();
_progressTimer = null;
setState(() => _isUploading = false);
}
}
@override
Widget build(BuildContext context) {
if (!kIsWeb) {
return const Center(
child: Text('This page is only available on the web.'),
);
}
return Scaffold(
appBar: AppBar(title: const Text('APK Update Uploader')),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _versionController,
decoration: const InputDecoration(
labelText: 'Version (e.g. 1.2.3)',
),
keyboardType: TextInputType.text,
validator: (v) =>
(v == null || v.isEmpty) ? 'Required' : null,
),
TextFormField(
controller: _minController,
decoration: const InputDecoration(
labelText: 'Min Version (e.g. 0.1.1)',
),
keyboardType: TextInputType.text,
validator: (v) =>
(v == null || v.isEmpty) ? 'Required' : null,
),
// Release notes: use Quill rich editor when available (web).
if (_quillController != null || kIsWeb) ...[
const SizedBox(height: 8),
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 ...[
TextFormField(
controller: _notesController,
decoration: const InputDecoration(
labelText: 'Release Notes',
),
maxLines: 3,
),
],
const SizedBox(height: 12),
Row(
children: [
ElevatedButton(
onPressed: _isUploading ? null : _pickApk,
child: const Text('Select APK'),
),
const SizedBox(width: 8),
Expanded(child: Text(_apkName ?? 'no file chosen')),
],
),
const SizedBox(height: 20),
if (_isUploading) ...[
// keep the animated indeterminate bar while showing the
// numeric progress percentage on top (smoothly animated).
Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: double.infinity,
child: LinearProgressIndicator(value: _progress),
),
// Smoothly animate the displayed percentage so updates feel fluid
TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: _realProgress),
duration: const Duration(milliseconds: 300),
builder: (context, value, child) {
final pct = (value * 100).clamp(0.0, 100.0);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surface.withAlpha(153),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${pct.toStringAsFixed(pct >= 10 ? 0 : 1)}% ',
style: Theme.of(
context,
).textTheme.bodyMedium,
),
);
},
),
],
),
if (_eta != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text('ETA: $_eta'),
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 150),
child: ListView(
shrinkWrap: true,
children: _logs.map((l) => Text(l)).toList(),
),
),
const SizedBox(height: 12),
],
if (_error != null)
Text(
_error!,
style: const TextStyle(color: Colors.red),
),
ElevatedButton(
onPressed: _isUploading ? null : _submit,
child: _isUploading
? const CircularProgressIndicator()
: const Text('Save'),
),
],
),
),
),
),
),
),
),
);
}
}