A more robust self hosted OTA updates implementation
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -6,6 +7,7 @@ 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"
|
||||
@@ -23,14 +25,20 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||
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 = 0.0; // 0..1
|
||||
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
|
||||
@@ -70,12 +78,42 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||
_versionController.text = bestRow['version_code']?.toString() ?? '';
|
||||
_minController.text =
|
||||
bestRow['min_version_required']?.toString() ?? '';
|
||||
_notesController.text = bestRow['release_notes'] ?? '';
|
||||
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() ?? '';
|
||||
_notesController.text = rows['release_notes'] ?? '';
|
||||
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 (_) {}
|
||||
}
|
||||
@@ -110,11 +148,16 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||
|
||||
final vcode = _versionController.text.trim();
|
||||
final minReq = _minController.text.trim();
|
||||
final notes = _notesController.text;
|
||||
String notes;
|
||||
if (_quillController != null) {
|
||||
notes = jsonEncode(_quillController!.document.toDelta().toJson());
|
||||
} else {
|
||||
notes = _notesController.text;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isUploading = true;
|
||||
_progress = 0.0;
|
||||
_progress = null; // show indeterminate while we attempt to start
|
||||
_eta = null;
|
||||
_logs.clear();
|
||||
_error = null;
|
||||
@@ -134,17 +177,28 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||
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
|
||||
// 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);
|
||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 200), (t) {
|
||||
final elapsed = stopwatch.elapsed.inMilliseconds / 1000.0;
|
||||
_startDelayTimer?.cancel();
|
||||
_startDelayTimer = Timer(const Duration(milliseconds: 700), () {
|
||||
// keep the bar indeterminate, but start updating the numeric progress
|
||||
setState(() {
|
||||
_progress = (elapsed / estimatedSeconds).clamp(0.0, 0.9);
|
||||
final remaining = (estimatedSeconds - elapsed).clamp(
|
||||
0.0,
|
||||
double.infinity,
|
||||
);
|
||||
_eta = '${remaining.toStringAsFixed(1)}s';
|
||||
_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';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,7 +222,7 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_progress = 0.95;
|
||||
_realProgress = 0.95;
|
||||
});
|
||||
// retrieve public URL; various SDK versions return different structures
|
||||
dynamic urlRes = client.storage.from('apk_updates').getPublicUrl(path);
|
||||
@@ -209,6 +263,7 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||
}, onConflict: 'version_code');
|
||||
await client.from('app_versions').delete().neq('version_code', vcode);
|
||||
setState(() {
|
||||
_realProgress = 1.0;
|
||||
_progress = 1.0;
|
||||
});
|
||||
if (mounted) {
|
||||
@@ -220,6 +275,8 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||
_logs.add('Error during upload: $e');
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
_startDelayTimer?.cancel();
|
||||
_startDelayTimer = null;
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
setState(() => _isUploading = false);
|
||||
@@ -236,72 +293,143 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||
|
||||
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,
|
||||
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),
|
||||
),
|
||||
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(),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Simple binary rain animation with a label for the update check splash.
|
||||
class UpdateCheckingScreen extends StatefulWidget {
|
||||
const UpdateCheckingScreen({super.key});
|
||||
|
||||
@override
|
||||
State<UpdateCheckingScreen> createState() => _UpdateCheckingScreenState();
|
||||
}
|
||||
|
||||
class _UpdateCheckingScreenState extends State<UpdateCheckingScreen> {
|
||||
static const int cols = 20;
|
||||
final List<_Drop> _drops = [];
|
||||
Timer? _timer;
|
||||
final Random _rng = Random();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
|
||||
_tick();
|
||||
});
|
||||
}
|
||||
|
||||
void _tick() {
|
||||
setState(() {
|
||||
if (_rng.nextDouble() < 0.3 || _drops.isEmpty) {
|
||||
_drops.add(
|
||||
_Drop(
|
||||
col: _rng.nextInt(cols),
|
||||
y: 0.0,
|
||||
speed: _rng.nextDouble() * 4 + 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
_drops.removeWhere((d) => d.y > MediaQuery.of(context).size.height);
|
||||
for (final d in _drops) {
|
||||
d.y += d.speed;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 40),
|
||||
Hero(
|
||||
tag: 'tasq-logo',
|
||||
child: Image.asset('assets/tasq_ico.png', height: 80, width: 80),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: CustomPaint(
|
||||
size: Size.infinite,
|
||||
painter: _BinaryRainPainter(_drops, cols, cs.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Checking for updates...',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: cs.onSurface.withAlpha((0.75 * 255).round()),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Drop {
|
||||
int col;
|
||||
double y;
|
||||
double speed;
|
||||
_Drop({required this.col, required this.y, required this.speed});
|
||||
}
|
||||
|
||||
class _BinaryRainPainter extends CustomPainter {
|
||||
static const double fontSize = 16;
|
||||
final List<_Drop> drops;
|
||||
final int cols;
|
||||
final Color textColor;
|
||||
|
||||
_BinaryRainPainter(this.drops, this.cols, this.textColor);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// paint variable not needed when drawing text
|
||||
final textStyle = TextStyle(
|
||||
color: textColor,
|
||||
fontSize: fontSize,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
);
|
||||
final cellW = size.width / cols;
|
||||
|
||||
for (final d in drops) {
|
||||
final text = (Random().nextBool() ? '1' : '0');
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(text: text, style: textStyle),
|
||||
textAlign: TextAlign.center,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
final x = d.col * cellW + (cellW - tp.width) / 2;
|
||||
final y = d.y;
|
||||
tp.paint(canvas, Offset(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _BinaryRainPainter old) => true;
|
||||
}
|
||||
Reference in New Issue
Block a user