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 createState() => _AppUpdateScreenState(); } class _AppUpdateScreenState extends ConsumerState { final _formKey = GlobalKey(); 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 _logs = []; Timer? _progressTimer; Timer? _startDelayTimer; String? _error; @override void initState() { super.initState(); _loadCurrent(); } Future _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? bestRow; if (rowList != null) { for (final r in rowList) { if (r is Map) { 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) { _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 _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 _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( 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'), ), ], ), ), ), ), ), ), ), ); } }