A more robust self hosted OTA updates implementation
This commit is contained in:
parent
9267ebee2c
commit
9bbaf67fef
|
|
@ -71,6 +71,16 @@
|
||||||
android:name="id.flutter.flutter_background_service.BackgroundService"
|
android:name="id.flutter.flutter_background_service.BackgroundService"
|
||||||
android:foregroundServiceType="location"
|
android:foregroundServiceType="location"
|
||||||
tools:replace="android:foregroundServiceType" />
|
tools:replace="android:foregroundServiceType" />
|
||||||
|
<!-- FileProvider for OTA plugin (ota_update) -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.ota_update_provider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/ota_update_file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,49 @@
|
||||||
package com.example.tasq
|
package com.example.tasq
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class MainActivity : FlutterFragmentActivity()
|
class MainActivity : FlutterFragmentActivity() {
|
||||||
|
private val CHANNEL = "tasq/ota"
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, _ ->
|
||||||
|
when (call.method) {
|
||||||
|
"openUnknownSources" -> {
|
||||||
|
try {
|
||||||
|
val intent = Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
|
||||||
|
intent.data = Uri.parse("package:" + applicationContext.packageName)
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
applicationContext.startActivity(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// ignore and return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"installApk" -> {
|
||||||
|
try {
|
||||||
|
val path = call.argument<String>("path") ?: return@setMethodCallHandler
|
||||||
|
val file = File(path)
|
||||||
|
val authority = applicationContext.packageName + ".ota_update_provider"
|
||||||
|
val uri = FileProvider.getUriForFile(applicationContext, authority, file)
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
|
intent.setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
applicationContext.startActivity(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// ignore - caller will surface error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
9
android/app/src/main/res/xml/ota_update_file_paths.xml
Normal file
9
android/app/src/main/res/xml/ota_update_file_paths.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Allow access to common app storage locations used by OTA plugin -->
|
||||||
|
<external-path name="external_files" path="." />
|
||||||
|
<external-files-path name="external_files_app" path="." />
|
||||||
|
<cache-path name="cache" path="." />
|
||||||
|
<external-cache-path name="external_cache" path="." />
|
||||||
|
<files-path name="files" path="." />
|
||||||
|
</paths>
|
||||||
|
|
@ -4,12 +4,14 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
import 'screens/update_check_screen.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
// removed unused imports
|
// removed unused imports
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'theme/app_theme.dart';
|
||||||
import 'providers/notifications_provider.dart';
|
import 'providers/notifications_provider.dart';
|
||||||
import 'providers/notification_navigation_provider.dart';
|
import 'providers/notification_navigation_provider.dart';
|
||||||
import 'utils/app_time.dart';
|
import 'utils/app_time.dart';
|
||||||
|
|
@ -19,6 +21,7 @@ import 'services/notification_service.dart';
|
||||||
import 'services/notification_bridge.dart';
|
import 'services/notification_bridge.dart';
|
||||||
import 'services/background_location_service.dart';
|
import 'services/background_location_service.dart';
|
||||||
import 'services/app_update_service.dart';
|
import 'services/app_update_service.dart';
|
||||||
|
import 'models/app_version.dart';
|
||||||
import 'widgets/update_dialog.dart';
|
import 'widgets/update_dialog.dart';
|
||||||
import 'utils/navigation.dart';
|
import 'utils/navigation.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
@ -593,29 +596,66 @@ Future<void> main() async {
|
||||||
runApp(
|
runApp(
|
||||||
UncontrolledProviderScope(
|
UncontrolledProviderScope(
|
||||||
container: _globalProviderContainer,
|
container: _globalProviderContainer,
|
||||||
child: const NotificationBridge(child: TasqApp()),
|
child: const UpdateCheckWrapper(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// perform update check once the first frame has rendered; errors are
|
// Post-startup registration removed: token registration is handled
|
||||||
// intentionally swallowed so a network outage doesn't block startup.
|
// centrally in the auth state change listener to avoid duplicate inserts.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
||||||
try {
|
|
||||||
final info = await AppUpdateService.instance.checkForUpdate();
|
|
||||||
if (info.isUpdateAvailable) {
|
|
||||||
showDialog(
|
|
||||||
context: globalNavigatorKey.currentContext!,
|
|
||||||
barrierDismissible: !info.isForceUpdate,
|
|
||||||
builder: (_) => UpdateDialog(info: info),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wrapper shown at app launch; performs update check and displays
|
||||||
|
/// [UpdateCheckingScreen] until complete.
|
||||||
|
class UpdateCheckWrapper extends StatefulWidget {
|
||||||
|
const UpdateCheckWrapper({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UpdateCheckWrapper> createState() => _UpdateCheckWrapperState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UpdateCheckWrapperState extends State<UpdateCheckWrapper> {
|
||||||
|
bool _done = false;
|
||||||
|
AppUpdateInfo? _info;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_performCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performCheck() async {
|
||||||
|
try {
|
||||||
|
_info = await AppUpdateService.instance.checkForUpdate();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('update check failed: $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
|
@override
|
||||||
// centrally in the auth state change listener to avoid duplicate inserts.
|
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 {
|
class NotificationSoundObserver extends ProviderObserver {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import 'package:flutter/material.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:pub_semver/pub_semver.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.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
|
/// 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"
|
/// associated metadata. After the APK is uploaded to the "apk_updates"
|
||||||
|
|
@ -23,14 +25,20 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
final _versionController = TextEditingController();
|
final _versionController = TextEditingController();
|
||||||
final _minController = TextEditingController();
|
final _minController = TextEditingController();
|
||||||
final _notesController = 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;
|
Uint8List? _apkBytes;
|
||||||
String? _apkName;
|
String? _apkName;
|
||||||
bool _isUploading = false;
|
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;
|
String? _eta;
|
||||||
final List<String> _logs = [];
|
final List<String> _logs = [];
|
||||||
Timer? _progressTimer;
|
Timer? _progressTimer;
|
||||||
|
Timer? _startDelayTimer;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -70,12 +78,42 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
_versionController.text = bestRow['version_code']?.toString() ?? '';
|
_versionController.text = bestRow['version_code']?.toString() ?? '';
|
||||||
_minController.text =
|
_minController.text =
|
||||||
bestRow['min_version_required']?.toString() ?? '';
|
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>) {
|
} else if (rows is Map<String, dynamic>) {
|
||||||
_versionController.text = rows['version_code']?.toString() ?? '';
|
_versionController.text = rows['version_code']?.toString() ?? '';
|
||||||
_minController.text = rows['min_version_required']?.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 (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
@ -110,11 +148,16 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
|
|
||||||
final vcode = _versionController.text.trim();
|
final vcode = _versionController.text.trim();
|
||||||
final minReq = _minController.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(() {
|
setState(() {
|
||||||
_isUploading = true;
|
_isUploading = true;
|
||||||
_progress = 0.0;
|
_progress = null; // show indeterminate while we attempt to start
|
||||||
_eta = null;
|
_eta = null;
|
||||||
_logs.clear();
|
_logs.clear();
|
||||||
_error = null;
|
_error = null;
|
||||||
|
|
@ -134,12 +177,22 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
final path = filename;
|
final path = filename;
|
||||||
_logs.add('Starting upload to bucket apk_updates, path: $path');
|
_logs.add('Starting upload to bucket apk_updates, path: $path');
|
||||||
final stopwatch = Stopwatch()..start();
|
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);
|
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) {
|
_progressTimer = Timer.periodic(const Duration(milliseconds: 200), (t) {
|
||||||
final elapsed = stopwatch.elapsed.inMilliseconds / 1000.0;
|
final elapsed = stopwatch.elapsed.inMilliseconds / 1000.0;
|
||||||
|
final pct = (elapsed / estimatedSeconds).clamp(0.0, 0.95);
|
||||||
setState(() {
|
setState(() {
|
||||||
_progress = (elapsed / estimatedSeconds).clamp(0.0, 0.9);
|
_realProgress = pct;
|
||||||
final remaining = (estimatedSeconds - elapsed).clamp(
|
final remaining = (estimatedSeconds - elapsed).clamp(
|
||||||
0.0,
|
0.0,
|
||||||
double.infinity,
|
double.infinity,
|
||||||
|
|
@ -147,6 +200,7 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
_eta = '${remaining.toStringAsFixed(1)}s';
|
_eta = '${remaining.toStringAsFixed(1)}s';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
final uploadRes = await client.storage
|
final uploadRes = await client.storage
|
||||||
.from('apk_updates')
|
.from('apk_updates')
|
||||||
|
|
@ -168,7 +222,7 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_progress = 0.95;
|
_realProgress = 0.95;
|
||||||
});
|
});
|
||||||
// retrieve public URL; various SDK versions return different structures
|
// retrieve public URL; various SDK versions return different structures
|
||||||
dynamic urlRes = client.storage.from('apk_updates').getPublicUrl(path);
|
dynamic urlRes = client.storage.from('apk_updates').getPublicUrl(path);
|
||||||
|
|
@ -209,6 +263,7 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
}, onConflict: 'version_code');
|
}, onConflict: 'version_code');
|
||||||
await client.from('app_versions').delete().neq('version_code', vcode);
|
await client.from('app_versions').delete().neq('version_code', vcode);
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_realProgress = 1.0;
|
||||||
_progress = 1.0;
|
_progress = 1.0;
|
||||||
});
|
});
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
@ -220,6 +275,8 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
_logs.add('Error during upload: $e');
|
_logs.add('Error during upload: $e');
|
||||||
setState(() => _error = e.toString());
|
setState(() => _error = e.toString());
|
||||||
} finally {
|
} finally {
|
||||||
|
_startDelayTimer?.cancel();
|
||||||
|
_startDelayTimer = null;
|
||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_progressTimer = null;
|
_progressTimer = null;
|
||||||
setState(() => _isUploading = false);
|
setState(() => _isUploading = false);
|
||||||
|
|
@ -236,7 +293,17 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('APK Update Uploader')),
|
appBar: AppBar(title: const Text('APK Update Uploader')),
|
||||||
body: Padding(
|
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),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
|
|
@ -249,7 +316,8 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
labelText: 'Version (e.g. 1.2.3)',
|
labelText: 'Version (e.g. 1.2.3)',
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.text,
|
keyboardType: TextInputType.text,
|
||||||
validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
|
validator: (v) =>
|
||||||
|
(v == null || v.isEmpty) ? 'Required' : null,
|
||||||
),
|
),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _minController,
|
controller: _minController,
|
||||||
|
|
@ -257,13 +325,30 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
labelText: 'Min Version (e.g. 0.1.1)',
|
labelText: 'Min Version (e.g. 0.1.1)',
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.text,
|
keyboardType: TextInputType.text,
|
||||||
validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
|
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(
|
TextFormField(
|
||||||
controller: _notesController,
|
controller: _notesController,
|
||||||
decoration: const InputDecoration(labelText: 'Release Notes'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Release Notes',
|
||||||
|
),
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
),
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -277,10 +362,46 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
if (_isUploading) ...[
|
if (_isUploading) ...[
|
||||||
LinearProgressIndicator(value: _progress),
|
// 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)
|
if (_eta != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 4.0),
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
child: Text('ETA: $_eta'),
|
child: Text('ETA: $_eta'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
@ -294,7 +415,10 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
if (_error != null)
|
if (_error != null)
|
||||||
Text(_error!, style: const TextStyle(color: Colors.red)),
|
Text(
|
||||||
|
_error!,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _isUploading ? null : _submit,
|
onPressed: _isUploading ? null : _submit,
|
||||||
child: _isUploading
|
child: _isUploading
|
||||||
|
|
@ -305,6 +429,10 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
127
lib/screens/update_check_screen.dart
Normal file
127
lib/screens/update_check_screen.dart
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,8 @@ import 'package:ota_update/ota_update.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:pub_semver/pub_semver.dart';
|
import 'package:pub_semver/pub_semver.dart';
|
||||||
import 'package:permission_handler/permission_handler.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 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
import '../models/app_version.dart';
|
import '../models/app_version.dart';
|
||||||
|
|
@ -25,21 +27,56 @@ class AppUpdateService {
|
||||||
|
|
||||||
final SupabaseClient _client = Supabase.instance.client;
|
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<void> _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
|
/// Fetches the most recent record from ``app_versions``. The table is
|
||||||
/// expected to contain a single row per release; we use the highest
|
/// expected to contain a single row per release; we use the highest
|
||||||
/// ``version_code`` so that historical entries may be kept if desired.
|
/// ``version_code`` so that historical entries may be kept if desired.
|
||||||
Future<AppVersion?> _fetchLatestVersion() async {
|
Future<AppVersion?> _fetchLatestVersion() async {
|
||||||
try {
|
try {
|
||||||
final Map<String, dynamic>? data = await _client
|
// Request all rows so we can compare semantic versions locally. The
|
||||||
.from('app_versions')
|
// Supabase client returns a List for select() (maybeSingle returns a Map).
|
||||||
.select()
|
final data = await _client.from('app_versions').select();
|
||||||
.order('version_code', ascending: false)
|
// The Supabase client returns a PostgrestList (List-like). Convert to
|
||||||
.limit(1)
|
// a Dart List and pick the highest semantic version.
|
||||||
.maybeSingle();
|
final list = data as List;
|
||||||
if (data == null) return null;
|
if (list.isEmpty) return null;
|
||||||
return AppVersion.fromMap(data);
|
|
||||||
|
Version? best;
|
||||||
|
Map<String, dynamic>? bestRow;
|
||||||
|
for (final item in list) {
|
||||||
|
if (item is Map) {
|
||||||
|
final map = Map<String, dynamic>.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<String, dynamic>.from(list.first));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// rethrow so callers can handle/log
|
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +97,11 @@ class AppUpdateService {
|
||||||
}
|
}
|
||||||
|
|
||||||
final pkg = await PackageInfo.fromPlatform();
|
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();
|
final serverVersion = await _fetchLatestVersion();
|
||||||
|
|
||||||
|
|
@ -114,12 +155,21 @@ class AppUpdateService {
|
||||||
|
|
||||||
final status = await Permission.requestInstallPackages.request();
|
final status = await Permission.requestInstallPackages.request();
|
||||||
if (!status.isGranted) {
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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<void>();
|
final completer = Completer<void>();
|
||||||
StreamSubscription<OtaEvent>? sub;
|
StreamSubscription<OtaEvent>? sub;
|
||||||
try {
|
try {
|
||||||
|
debugPrint('OTA: starting attempt $attempt/$maxAttempts');
|
||||||
sub = OtaUpdate()
|
sub = OtaUpdate()
|
||||||
.execute(
|
.execute(
|
||||||
downloadUrl,
|
downloadUrl,
|
||||||
|
|
@ -128,32 +178,177 @@ class AppUpdateService {
|
||||||
)
|
)
|
||||||
.listen(
|
.listen(
|
||||||
(event) {
|
(event) {
|
||||||
switch (event.status) {
|
final statusStr = event.status.toString();
|
||||||
case OtaStatus.DOWNLOADING:
|
// DOWNLOADING usually reports percent in event.value
|
||||||
|
if (statusStr.endsWith('DOWNLOADING')) {
|
||||||
final pct = double.tryParse(event.value ?? '0') ?? 0;
|
final pct = double.tryParse(event.value ?? '0') ?? 0;
|
||||||
onProgress(pct / 100.0);
|
try {
|
||||||
break;
|
_setSmoothedProgress(pct / 100.0, onProgress);
|
||||||
case OtaStatus.INSTALLING:
|
} catch (_) {}
|
||||||
|
} else if (statusStr.endsWith('INSTALLING')) {
|
||||||
if (!completer.isCompleted) completer.complete();
|
if (!completer.isCompleted) completer.complete();
|
||||||
break;
|
} else if (statusStr.toLowerCase().contains('cancel')) {
|
||||||
default:
|
|
||||||
// treat all other statuses as failure; they provide a string
|
|
||||||
// description in `event.value`.
|
|
||||||
if (!completer.isCompleted) {
|
if (!completer.isCompleted) {
|
||||||
completer.completeError(
|
completer.completeError(
|
||||||
Exception('OTA update failed: ${event.status}'),
|
Exception('OTA cancelled: ${event.value ?? ''}'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
} 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) {
|
onError: (e) {
|
||||||
if (!completer.isCompleted) completer.completeError(e);
|
if (!completer.isCompleted) completer.completeError(e);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await completer.future;
|
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 {
|
} finally {
|
||||||
if (sub != null) await sub.cancel();
|
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<String> _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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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 '../models/app_version.dart';
|
||||||
import '../services/app_update_service.dart';
|
import '../services/app_update_service.dart';
|
||||||
|
|
@ -16,9 +20,11 @@ class UpdateDialog extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UpdateDialogState extends State<UpdateDialog> {
|
class _UpdateDialogState extends State<UpdateDialog> {
|
||||||
double _progress = 0;
|
double _realProgress = 0.0;
|
||||||
bool _downloading = false;
|
bool _downloading = false;
|
||||||
bool _failed = false;
|
bool _failed = false;
|
||||||
|
List<dynamic>? _notesDelta;
|
||||||
|
String? _notesPlain;
|
||||||
|
|
||||||
Future<void> _startDownload() async {
|
Future<void> _startDownload() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -29,7 +35,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||||
try {
|
try {
|
||||||
await AppUpdateService.instance.downloadAndInstallApk(
|
await AppUpdateService.instance.downloadAndInstallApk(
|
||||||
widget.info.latestVersion!.downloadUrl,
|
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
|
// once the installer launches the app is likely to be stopped; we
|
||||||
// don't pop the dialog explicitly.
|
// don't pop the dialog explicitly.
|
||||||
|
|
@ -49,6 +55,20 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final notes = widget.info.latestVersion?.releaseNotes ?? '';
|
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
|
// WillPopScope is deprecated in newer Flutter versions but PopScope
|
||||||
// has a different API; to avoid breaking changes we continue to use the
|
// has a different API; to avoid breaking changes we continue to use the
|
||||||
// old widget and suppress the warning.
|
// old widget and suppress the warning.
|
||||||
|
|
@ -65,20 +85,53 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (notes.isNotEmpty) ...[Text(notes), const SizedBox(height: 12)],
|
// Render release notes: prefer Quill delta if available
|
||||||
if (_downloading)
|
if (_notesDelta != null)
|
||||||
Column(
|
SizedBox(
|
||||||
children: [
|
height: 250,
|
||||||
LinearProgressIndicator(value: _progress),
|
child: SingleChildScrollView(
|
||||||
const SizedBox(height: 8),
|
child: RichText(
|
||||||
Text('${(_progress * 100).toStringAsFixed(0)}%'),
|
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<double>(
|
||||||
|
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)
|
if (_failed)
|
||||||
const Text(
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 8.0),
|
||||||
|
child: Text(
|
||||||
'An error occurred while downloading. Please try again.',
|
'An error occurred while downloading. Please try again.',
|
||||||
style: TextStyle(color: Colors.red),
|
style: TextStyle(color: Colors.red),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: _buildActions(),
|
actions: _buildActions(),
|
||||||
|
|
@ -87,26 +140,108 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildActions() {
|
List<Widget> _buildActions() {
|
||||||
if (_downloading) {
|
final actions = <Widget>[];
|
||||||
// don't show any actions while the apk is being fetched
|
|
||||||
return <Widget>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (widget.info.isForceUpdate) {
|
if (!widget.info.isForceUpdate && !_downloading) {
|
||||||
return [
|
actions.add(
|
||||||
FilledButton(
|
|
||||||
onPressed: _startDownload,
|
|
||||||
child: const Text('Update Now'),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
child: const Text('Later'),
|
child: const Text('Later'),
|
||||||
),
|
),
|
||||||
FilledButton(onPressed: _startDownload, child: const Text('Update Now')),
|
);
|
||||||
];
|
}
|
||||||
|
|
||||||
|
actions.add(
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _downloading ? null : _startDownload,
|
||||||
|
child: _downloading
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: const Text('Update Now'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
TextSpan _deltaToTextSpan(List<dynamic> delta, TextStyle? baseStyle) {
|
||||||
|
final children = <TextSpan>[];
|
||||||
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user