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:foregroundServiceType="location"
|
||||
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>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
|
|
|||
|
|
@ -1,5 +1,49 @@
|
|||
package com.example.tasq
|
||||
|
||||
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: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<void> 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<UpdateCheckWrapper> createState() => _UpdateCheckWrapperState();
|
||||
}
|
||||
|
||||
class _UpdateCheckWrapperState extends State<UpdateCheckWrapper> {
|
||||
bool _done = false;
|
||||
AppUpdateInfo? _info;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_performCheck();
|
||||
}
|
||||
|
||||
Future<void> _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 {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
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: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<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
|
||||
/// expected to contain a single row per release; we use the highest
|
||||
/// ``version_code`` so that historical entries may be kept if desired.
|
||||
Future<AppVersion?> _fetchLatestVersion() async {
|
||||
try {
|
||||
final Map<String, dynamic>? 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<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) {
|
||||
// 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<void>();
|
||||
StreamSubscription<OtaEvent>? 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<void>();
|
||||
StreamSubscription<OtaEvent>? 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<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';
|
||||
// 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<UpdateDialog> {
|
||||
double _progress = 0;
|
||||
double _realProgress = 0.0;
|
||||
bool _downloading = false;
|
||||
bool _failed = false;
|
||||
List<dynamic>? _notesDelta;
|
||||
String? _notesPlain;
|
||||
|
||||
Future<void> _startDownload() async {
|
||||
setState(() {
|
||||
|
|
@ -29,7 +35,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||
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<UpdateDialog> {
|
|||
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<UpdateDialog> {
|
|||
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<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)
|
||||
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<UpdateDialog> {
|
|||
}
|
||||
|
||||
List<Widget> _buildActions() {
|
||||
if (_downloading) {
|
||||
// don't show any actions while the apk is being fetched
|
||||
return <Widget>[];
|
||||
}
|
||||
final actions = <Widget>[];
|
||||
|
||||
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<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