439 lines
16 KiB
Dart
439 lines
16 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;
|
|
|
|
/// 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;
|
|
// 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();
|
|
_loadCurrent();
|
|
}
|
|
|
|
Future<void> _loadCurrent() async {
|
|
try {
|
|
final client = Supabase.instance.client;
|
|
final rows = await client.from('app_versions').select().maybeSingle();
|
|
// 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) {
|
|
_versionController.text = bestRow['version_code']?.toString() ?? '';
|
|
_minController.text =
|
|
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 {
|
|
_notesController.text = rn.toString();
|
|
}
|
|
} catch (_) {
|
|
_notesController.text = rn.toString();
|
|
}
|
|
}
|
|
}
|
|
} else if (rows is Map<String, dynamic>) {
|
|
_versionController.text = rows['version_code']?.toString() ?? '';
|
|
_minController.text = 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 {
|
|
_notesController.text = rn as String;
|
|
}
|
|
} catch (_) {
|
|
_notesController.text = rn as String;
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_versionController.dispose();
|
|
_minController.dispose();
|
|
_notesController.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> _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';
|
|
});
|
|
});
|
|
});
|
|
|
|
final uploadRes = await client.storage
|
|
.from('apk_updates')
|
|
.uploadBinary(
|
|
path,
|
|
_apkBytes!,
|
|
fileOptions: const FileOptions(
|
|
upsert: true,
|
|
contentType: 'application/vnd.android.package-archive',
|
|
),
|
|
);
|
|
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());
|
|
} 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) ...[
|
|
// toolbar omitted (package version may not export it)
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
height: 200,
|
|
child: quill.QuillEditor.basic(
|
|
controller:
|
|
_quillController ??
|
|
quill.QuillController.basic(),
|
|
),
|
|
),
|
|
] 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'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|