diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 3d8fcdf0..77118dae 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -71,6 +71,16 @@
android:name="id.flutter.flutter_background_service.BackgroundService"
android:foregroundServiceType="location"
tools:replace="android:foregroundServiceType" />
+
+
+
+
+
+
+
+
+
+
diff --git a/lib/main.dart b/lib/main.dart
index c5c7d5f6..6bee3a02 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -4,12 +4,14 @@ import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
+import 'screens/update_check_screen.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'firebase_options.dart';
// removed unused imports
import 'app.dart';
+import 'theme/app_theme.dart';
import 'providers/notifications_provider.dart';
import 'providers/notification_navigation_provider.dart';
import 'utils/app_time.dart';
@@ -19,6 +21,7 @@ import 'services/notification_service.dart';
import 'services/notification_bridge.dart';
import 'services/background_location_service.dart';
import 'services/app_update_service.dart';
+import 'models/app_version.dart';
import 'widgets/update_dialog.dart';
import 'utils/navigation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
@@ -593,29 +596,66 @@ Future main() async {
runApp(
UncontrolledProviderScope(
container: _globalProviderContainer,
- child: const NotificationBridge(child: TasqApp()),
+ child: const UpdateCheckWrapper(),
),
);
- // perform update check once the first frame has rendered; errors are
- // intentionally swallowed so a network outage doesn't block startup.
- WidgetsBinding.instance.addPostFrameCallback((_) async {
+ // Post-startup registration removed: token registration is handled
+ // centrally in the auth state change listener to avoid duplicate inserts.
+}
+
+/// Wrapper shown at app launch; performs update check and displays
+/// [UpdateCheckingScreen] until complete.
+class UpdateCheckWrapper extends StatefulWidget {
+ const UpdateCheckWrapper({super.key});
+
+ @override
+ State createState() => _UpdateCheckWrapperState();
+}
+
+class _UpdateCheckWrapperState extends State {
+ bool _done = false;
+ AppUpdateInfo? _info;
+
+ @override
+ void initState() {
+ super.initState();
+ _performCheck();
+ }
+
+ Future _performCheck() async {
try {
- final info = await AppUpdateService.instance.checkForUpdate();
- if (info.isUpdateAvailable) {
- showDialog(
- context: globalNavigatorKey.currentContext!,
- barrierDismissible: !info.isForceUpdate,
- builder: (_) => UpdateDialog(info: info),
- );
- }
+ _info = await AppUpdateService.instance.checkForUpdate();
} catch (e) {
debugPrint('update check failed: $e');
}
- });
+ if (mounted) {
+ setState(() => _done = true);
+ if (_info?.isUpdateAvailable == true) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ showDialog(
+ context: globalNavigatorKey.currentContext!,
+ barrierDismissible: !_info!.isForceUpdate,
+ builder: (_) => UpdateDialog(info: _info!),
+ );
+ });
+ }
+ }
+ }
- // Post-startup registration removed: token registration is handled
- // centrally in the auth state change listener to avoid duplicate inserts.
+ @override
+ Widget build(BuildContext context) {
+ if (!_done) {
+ return MaterialApp(
+ debugShowCheckedModeBanner: false,
+ theme: AppTheme.light(),
+ darkTheme: AppTheme.dark(),
+ themeMode: ThemeMode.system,
+ home: const UpdateCheckingScreen(),
+ );
+ }
+ return const NotificationBridge(child: TasqApp());
+ }
}
class NotificationSoundObserver extends ProviderObserver {
diff --git a/lib/screens/admin/app_update_screen.dart b/lib/screens/admin/app_update_screen.dart
index 996c3b65..28cab8ae 100644
--- a/lib/screens/admin/app_update_screen.dart
+++ b/lib/screens/admin/app_update_screen.dart
@@ -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 {
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 _logs = [];
Timer? _progressTimer;
+ Timer? _startDelayTimer;
String? _error;
@override
@@ -70,12 +78,42 @@ class _AppUpdateScreenState extends ConsumerState {
_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) {
_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 {
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 {
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 {
}
}
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 {
}, 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 {
_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 {
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(
+ 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'),
),
- ],
+ ),
),
),
),
diff --git a/lib/screens/update_check_screen.dart b/lib/screens/update_check_screen.dart
new file mode 100644
index 00000000..e086acb8
--- /dev/null
+++ b/lib/screens/update_check_screen.dart
@@ -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 createState() => _UpdateCheckingScreenState();
+}
+
+class _UpdateCheckingScreenState extends State {
+ 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;
+}
diff --git a/lib/services/app_update_service.dart b/lib/services/app_update_service.dart
index 3b557f2e..b2f92e37 100644
--- a/lib/services/app_update_service.dart
+++ b/lib/services/app_update_service.dart
@@ -5,6 +5,8 @@ import 'package:ota_update/ota_update.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:permission_handler/permission_handler.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter/foundation.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/app_version.dart';
@@ -25,21 +27,56 @@ class AppUpdateService {
final SupabaseClient _client = Supabase.instance.client;
+ static const MethodChannel _platform = MethodChannel('tasq/ota');
+
+ // Smoothed progress state — the service smooths raw percent updates into
+ // small animated increments so UI progress displays feel responsive.
+ double _smoothedProgress = 0.0;
+ double _smoothedTarget = 0.0;
+ Timer? _smoothedTimer;
+
+ Future _openUnknownAppSourcesSettings() async {
+ try {
+ await _platform.invokeMethod('openUnknownSources');
+ } on PlatformException {
+ // ignore if platform method is not implemented
+ }
+ }
+
/// Fetches the most recent record from ``app_versions``. The table is
/// expected to contain a single row per release; we use the highest
/// ``version_code`` so that historical entries may be kept if desired.
Future _fetchLatestVersion() async {
try {
- final Map? data = await _client
- .from('app_versions')
- .select()
- .order('version_code', ascending: false)
- .limit(1)
- .maybeSingle();
- if (data == null) return null;
- return AppVersion.fromMap(data);
+ // Request all rows so we can compare semantic versions locally. The
+ // Supabase client returns a List for select() (maybeSingle returns a Map).
+ final data = await _client.from('app_versions').select();
+ // The Supabase client returns a PostgrestList (List-like). Convert to
+ // a Dart List and pick the highest semantic version.
+ final list = data as List;
+ if (list.isEmpty) return null;
+
+ Version? best;
+ Map? bestRow;
+ for (final item in list) {
+ if (item is Map) {
+ final map = Map.from(item);
+ final v = map['version_code']?.toString() ?? '';
+ try {
+ final parsed = Version.parse(v);
+ if (best == null || parsed > best) {
+ best = parsed;
+ bestRow = map;
+ }
+ } catch (_) {
+ // ignore non-semver rows
+ }
+ }
+ }
+ if (bestRow != null) return AppVersion.fromMap(bestRow);
+ // fallback to the first row
+ return AppVersion.fromMap(Map.from(list.first));
} catch (e) {
- // rethrow so callers can handle/log
rethrow;
}
}
@@ -60,7 +97,11 @@ class AppUpdateService {
}
final pkg = await PackageInfo.fromPlatform();
- final currentVersion = pkg.buildNumber.trim();
+ // prefer the version name which commonly holds semantic versions like
+ // "1.2.3". fall back to buildNumber when version is empty.
+ final ver = pkg.version.trim();
+ final build = pkg.buildNumber.trim();
+ final currentVersion = ver.isNotEmpty ? ver : build;
final serverVersion = await _fetchLatestVersion();
@@ -114,46 +155,200 @@ class AppUpdateService {
final status = await Permission.requestInstallPackages.request();
if (!status.isGranted) {
- throw Exception('installation permission denied');
+ // Open the system settings page so the user can enable "Install unknown apps"
+ try {
+ await _openUnknownAppSourcesSettings();
+ } catch (_) {}
+ throw Exception('installation permission denied; opened settings');
}
- final completer = Completer();
- StreamSubscription? sub;
- try {
- sub = OtaUpdate()
- .execute(
- downloadUrl,
- destinationFilename:
- 'app_${DateTime.now().millisecondsSinceEpoch}.apk',
- )
- .listen(
- (event) {
- switch (event.status) {
- case OtaStatus.DOWNLOADING:
+ // Wrap OTA download in a retry loop to handle transient network issues
+ // (socket closed / timeout) that the underlying okhttp client may throw.
+ const int maxAttempts = 3;
+ for (int attempt = 1; attempt <= maxAttempts; attempt++) {
+ final completer = Completer();
+ StreamSubscription? sub;
+ try {
+ debugPrint('OTA: starting attempt $attempt/$maxAttempts');
+ sub = OtaUpdate()
+ .execute(
+ downloadUrl,
+ destinationFilename:
+ 'app_${DateTime.now().millisecondsSinceEpoch}.apk',
+ )
+ .listen(
+ (event) {
+ final statusStr = event.status.toString();
+ // DOWNLOADING usually reports percent in event.value
+ if (statusStr.endsWith('DOWNLOADING')) {
final pct = double.tryParse(event.value ?? '0') ?? 0;
- onProgress(pct / 100.0);
- break;
- case OtaStatus.INSTALLING:
+ try {
+ _setSmoothedProgress(pct / 100.0, onProgress);
+ } catch (_) {}
+ } else if (statusStr.endsWith('INSTALLING')) {
if (!completer.isCompleted) completer.complete();
- break;
- default:
- // treat all other statuses as failure; they provide a string
- // description in `event.value`.
+ } else if (statusStr.toLowerCase().contains('cancel')) {
if (!completer.isCompleted) {
completer.completeError(
- Exception('OTA update failed: ${event.status}'),
+ Exception('OTA cancelled: ${event.value ?? ''}'),
);
}
- break;
- }
- },
- onError: (e) {
- if (!completer.isCompleted) completer.completeError(e);
- },
- );
- await completer.future;
- } finally {
- if (sub != null) await sub.cancel();
+ } else if (statusStr.toLowerCase().contains('permission') ||
+ (event.value ?? '').toLowerCase().contains('permission')) {
+ try {
+ _openUnknownAppSourcesSettings();
+ } catch (_) {}
+ if (!completer.isCompleted) {
+ completer.completeError(
+ Exception(
+ 'OTA permission not granted: ${event.value ?? ''}',
+ ),
+ );
+ }
+ } else {
+ // Only treat events containing known network/error keywords as fatal
+ final val = (event.value ?? '').toLowerCase();
+ if (val.contains('timeout') ||
+ val.contains('socket') ||
+ val.contains('closed') ||
+ statusStr.toLowerCase().contains('error')) {
+ if (!completer.isCompleted) {
+ completer.completeError(
+ Exception(
+ 'OTA update failed ($statusStr): ${event.value ?? ''}',
+ ),
+ );
+ }
+ } else {
+ // Unknown non-error status — ignore to avoid premature failure.
+ }
+ }
+ },
+ onError: (e) {
+ if (!completer.isCompleted) completer.completeError(e);
+ },
+ );
+
+ await completer.future;
+ debugPrint('OTA: attempt $attempt succeeded');
+ return;
+ } catch (e, st) {
+ debugPrint('OTA: attempt $attempt failed: $e\n$st');
+ // If last attempt, rethrow so caller can handle/report it.
+ if (attempt == maxAttempts) rethrow;
+
+ final msg = e.toString();
+ // Retry only on network/timeouts/socket-related failures.
+ if (msg.toLowerCase().contains('timeout') ||
+ msg.toLowerCase().contains('socket') ||
+ msg.toLowerCase().contains('closed')) {
+ final backoff = Duration(seconds: 2 * attempt);
+ debugPrint('OTA: retrying after ${backoff.inSeconds}s backoff');
+ await Future.delayed(backoff);
+ continue; // next attempt
+ }
+
+ // For non-network errors, do not retry.
+ rethrow;
+ } finally {
+ if (sub != null) await sub.cancel();
+ _smoothedTimer?.cancel();
+ _smoothedTimer = null;
+ }
+ }
+ // If we reach here all ota_update attempts failed; try a Dart HTTP download
+ // to a temp file and ask native code to install it via FileProvider.
+ try {
+ debugPrint('OTA: falling back to Dart HTTP download');
+ final filePath = await _downloadApkToFile(downloadUrl, onProgress);
+ try {
+ await _platform.invokeMethod('installApk', {'path': filePath});
+ } on PlatformException catch (e) {
+ throw Exception('Failed to invoke native installer: ${e.message}');
+ }
+ } catch (e) {
+ rethrow;
}
}
+
+ Future _downloadApkToFile(
+ String url,
+ void Function(double) onProgress,
+ ) async {
+ final uri = Uri.parse(url);
+ final client = HttpClient();
+ client.connectionTimeout = const Duration(minutes: 5);
+ final req = await client.getUrl(uri);
+ final resp = await req.close();
+ if (resp.statusCode != 200) {
+ throw Exception('Download failed: HTTP ${resp.statusCode}');
+ }
+
+ final tempDir = Directory.systemTemp.createTempSync('ota_');
+ final file = File(
+ '${tempDir.path}/app_${DateTime.now().millisecondsSinceEpoch}.apk',
+ );
+ final sink = file.openWrite();
+ final contentLength = resp.contentLength;
+ int received = 0;
+ try {
+ await for (final chunk in resp) {
+ received += chunk.length;
+ sink.add(chunk);
+ if (contentLength > 0) {
+ try {
+ _setSmoothedProgress(received / contentLength, onProgress);
+ } catch (_) {}
+ }
+ }
+ } finally {
+ await sink.close();
+ client.close(force: true);
+ _smoothedTimer?.cancel();
+ _smoothedTimer = null;
+ _smoothedProgress = 0.0;
+ }
+ return file.path;
+ }
+
+ void _setSmoothedProgress(double target, void Function(double) onProgress) {
+ // Clamp
+ target = target.clamp(0.0, 1.0);
+
+ // If target is less than or equal to current, update immediately.
+ if (target <= _smoothedProgress) {
+ _smoothedTarget = target;
+ _smoothedProgress = target;
+ try {
+ onProgress(_smoothedProgress);
+ } catch (_) {}
+ return;
+ }
+
+ // Otherwise, set the target and ensure a single periodic timer is running
+ // which will nudge _smoothedProgress toward _smoothedTarget. This avoids
+ // canceling/creating timers on every small update which caused batching.
+ _smoothedTarget = target;
+ if (_smoothedTimer != null) return;
+
+ const tickMs = 100;
+ _smoothedTimer = Timer.periodic(const Duration(milliseconds: tickMs), (t) {
+ // Move a fraction toward the target for a smooth ease-out feel.
+ final remaining = _smoothedTarget - _smoothedProgress;
+ final step = (remaining * 0.5).clamp(0.002, 0.05);
+ _smoothedProgress = (_smoothedProgress + step).clamp(0.0, 1.0);
+ try {
+ onProgress(_smoothedProgress);
+ } catch (_) {}
+ // Stop when we're very close to the target.
+ if ((_smoothedTarget - _smoothedProgress) < 0.001) {
+ _smoothedProgress = _smoothedTarget;
+ try {
+ onProgress(_smoothedProgress);
+ } catch (_) {}
+ _smoothedTimer?.cancel();
+ _smoothedTimer = null;
+ }
+ });
+ }
}
diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart
index 1768317d..43f9f431 100644
--- a/lib/widgets/update_dialog.dart
+++ b/lib/widgets/update_dialog.dart
@@ -1,4 +1,8 @@
+import 'dart:convert';
+
import 'package:flutter/material.dart';
+// We render Quill deltas here without depending on the flutter_quill editor
+// API to avoid analyzer/API mismatches; we support common inline styles.
import '../models/app_version.dart';
import '../services/app_update_service.dart';
@@ -16,9 +20,11 @@ class UpdateDialog extends StatefulWidget {
}
class _UpdateDialogState extends State {
- double _progress = 0;
+ double _realProgress = 0.0;
bool _downloading = false;
bool _failed = false;
+ List? _notesDelta;
+ String? _notesPlain;
Future _startDownload() async {
setState(() {
@@ -29,7 +35,7 @@ class _UpdateDialogState extends State {
try {
await AppUpdateService.instance.downloadAndInstallApk(
widget.info.latestVersion!.downloadUrl,
- onProgress: (p) => setState(() => _progress = p),
+ onProgress: (p) => setState(() => _realProgress = p),
);
// once the installer launches the app is likely to be stopped; we
// don't pop the dialog explicitly.
@@ -49,6 +55,20 @@ class _UpdateDialogState extends State {
Widget build(BuildContext context) {
final notes = widget.info.latestVersion?.releaseNotes ?? '';
+ // parse release notes into a Quill delta list if possible
+ if (_notesDelta == null && _notesPlain == null && notes.isNotEmpty) {
+ try {
+ final parsed = jsonDecode(notes);
+ if (parsed is List) {
+ _notesDelta = parsed;
+ } else {
+ _notesPlain = notes;
+ }
+ } catch (_) {
+ _notesPlain = notes;
+ }
+ }
+
// WillPopScope is deprecated in newer Flutter versions but PopScope
// has a different API; to avoid breaking changes we continue to use the
// old widget and suppress the warning.
@@ -65,19 +85,52 @@ class _UpdateDialogState extends State {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- if (notes.isNotEmpty) ...[Text(notes), const SizedBox(height: 12)],
- if (_downloading)
- Column(
- children: [
- LinearProgressIndicator(value: _progress),
- const SizedBox(height: 8),
- Text('${(_progress * 100).toStringAsFixed(0)}%'),
- ],
+ // Render release notes: prefer Quill delta if available
+ if (_notesDelta != null)
+ SizedBox(
+ height: 250,
+ child: SingleChildScrollView(
+ child: RichText(
+ text: _deltaToTextSpan(
+ _notesDelta!,
+ Theme.of(context).textTheme.bodyMedium,
+ ),
+ ),
+ ),
),
+ if (_notesDelta == null &&
+ _notesPlain != null &&
+ _notesPlain!.isNotEmpty)
+ SizedBox(
+ height: 250,
+ child: SingleChildScrollView(
+ child: SelectableText(_notesPlain!),
+ ),
+ ),
+ const SizedBox(height: 12),
+ if (_downloading) ...[
+ SizedBox(
+ width: double.infinity,
+ child: const LinearProgressIndicator(value: null),
+ ),
+ const SizedBox(height: 8),
+ TweenAnimationBuilder(
+ tween: Tween(begin: 0.0, end: _realProgress),
+ duration: const Duration(milliseconds: 300),
+ builder: (context, value, child) {
+ return Text(
+ '${(value * 100).toStringAsFixed(value * 100 >= 10 ? 0 : 1)}%',
+ );
+ },
+ ),
+ ],
if (_failed)
- const Text(
- 'An error occurred while downloading. Please try again.',
- style: TextStyle(color: Colors.red),
+ const Padding(
+ padding: EdgeInsets.only(top: 8.0),
+ child: Text(
+ 'An error occurred while downloading. Please try again.',
+ style: TextStyle(color: Colors.red),
+ ),
),
],
),
@@ -87,26 +140,108 @@ class _UpdateDialogState extends State {
}
List _buildActions() {
- if (_downloading) {
- // don't show any actions while the apk is being fetched
- return [];
- }
+ final actions = [];
- if (widget.info.isForceUpdate) {
- return [
- FilledButton(
- onPressed: _startDownload,
- child: const Text('Update Now'),
+ if (!widget.info.isForceUpdate && !_downloading) {
+ actions.add(
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('Later'),
),
- ];
+ );
}
- return [
- TextButton(
- onPressed: () => Navigator.of(context).pop(),
- child: const Text('Later'),
+ actions.add(
+ FilledButton(
+ onPressed: _downloading ? null : _startDownload,
+ child: _downloading
+ ? const CircularProgressIndicator()
+ : const Text('Update Now'),
),
- FilledButton(onPressed: _startDownload, child: const Text('Update Now')),
- ];
+ );
+
+ return actions;
+ }
+
+ TextSpan _deltaToTextSpan(List delta, TextStyle? baseStyle) {
+ final children = [];
+
+ TextStyle styleFromAttributes(Map? attrs) {
+ var s = baseStyle ?? const TextStyle();
+ if (attrs == null) return s;
+ if (attrs['header'] != null) {
+ final level = attrs['header'] is int ? attrs['header'] as int : 1;
+ s = s.copyWith(
+ fontSize: 18.0 - (level - 1) * 2,
+ fontWeight: FontWeight.bold,
+ );
+ }
+ if (attrs['bold'] == true) s = s.copyWith(fontWeight: FontWeight.bold);
+ if (attrs['italic'] == true) s = s.copyWith(fontStyle: FontStyle.italic);
+ if (attrs['underline'] == true) {
+ s = s.copyWith(decoration: TextDecoration.underline);
+ }
+ if (attrs['strike'] == true) {
+ s = s.copyWith(decoration: TextDecoration.lineThrough);
+ }
+ if (attrs['color'] is String) {
+ try {
+ final col = attrs['color'] as String;
+ // simple support for hex colors like #rrggbb
+ if (col.startsWith('#') && (col.length == 7 || col.length == 9)) {
+ final hex = col.replaceFirst('#', '');
+ final value = int.parse(hex, radix: 16);
+ s = s.copyWith(
+ color: Color((hex.length == 6 ? 0xFF000000 : 0) | value),
+ );
+ }
+ } catch (_) {}
+ }
+ return s;
+ }
+
+ for (final op in delta) {
+ if (op is Map && op.containsKey('insert')) {
+ final insert = op['insert'];
+ final attrs = op['attributes'] as Map?;
+ String text;
+ if (insert is String) {
+ text = insert;
+ } else if (insert is Map && insert.containsKey('image')) {
+ // render image as alt text placeholder
+ text = '[image]';
+ } else {
+ text = insert.toString();
+ }
+
+ children.add(TextSpan(text: text, style: styleFromAttributes(attrs)));
+ }
+ }
+
+ return TextSpan(children: children, style: baseStyle);
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ // Pre-parse release notes so heavy JSON parsing doesn't block UI later
+ final notes = widget.info.latestVersion?.releaseNotes ?? '';
+ if (notes.isNotEmpty) {
+ try {
+ final parsed = jsonDecode(notes);
+ if (parsed is List) {
+ _notesDelta = parsed;
+ } else {
+ _notesPlain = notes;
+ }
+ } catch (_) {
+ _notesPlain = notes;
+ }
+ }
+ }
+
+ @override
+ void dispose() {
+ super.dispose();
}
}