tasq/lib/screens/admin/app_update_screen.dart

285 lines
9.4 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.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();
Uint8List? _apkBytes;
String? _apkName;
bool _isUploading = false;
double _progress = 0.0; // 0..1
String? _eta;
final List<String> _logs = [];
Timer? _progressTimer;
String? _error;
@override
void initState() {
super.initState();
_loadCurrent();
}
Future<void> _loadCurrent() async {
try {
final client = Supabase.instance.client;
final data = await client
.from('app_versions')
.select()
.order('version_code', ascending: false)
.limit(1)
.maybeSingle();
if (data is Map<String, dynamic>) {
_versionController.text = data['version_code']?.toString() ?? '';
_minController.text = data['min_version_required']?.toString() ?? '';
_notesController.text = data['release_notes'] ?? '';
}
} 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();
final notes = _notesController.text;
setState(() {
_isUploading = true;
_progress = 0.0;
_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();
// we cannot track real progress from the SDK, so fake a linear update
final estimatedSeconds = (_apkBytes!.length / 250000).clamp(1, 30);
_progressTimer = Timer.periodic(const Duration(milliseconds: 200), (t) {
final elapsed = stopwatch.elapsed.inMilliseconds / 1000.0;
setState(() {
_progress = (elapsed / estimatedSeconds).clamp(0.0, 0.9);
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(() {
_progress = 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(() {
_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 {
_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: 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,
),
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) ...[
LinearProgressIndicator(value: _progress),
if (_eta != null)
Padding(
padding: const EdgeInsets.only(top: 4.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'),
),
],
),
),
),
);
}
}