From 217be08231e08ff23c121256ec6334b2088f2ccd Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Thu, 30 Jul 2026 04:46:03 +0200 Subject: [PATCH] feat: fix debug updater --- assets/products/duvel.png | Bin 0 -> 4510 bytes assets/products/koffie.jpg | Bin 0 -> 10131 bytes lib/app/router.dart | 18 +- lib/utils/app_updater.dart | 56 +--- .../update_progress_view_model.dart | 181 +++++++++++ lib/views/dev_menu_view.dart | 123 ++++++- lib/views/settings_view.dart | 27 +- lib/views/update_progress_view.dart | 300 ++++++++++++++++++ pubspec.yaml | 2 +- 9 files changed, 616 insertions(+), 91 deletions(-) create mode 100644 assets/products/duvel.png create mode 100644 assets/products/koffie.jpg create mode 100644 lib/viewmodels/update_progress_view_model.dart create mode 100644 lib/views/update_progress_view.dart diff --git a/assets/products/duvel.png b/assets/products/duvel.png new file mode 100644 index 0000000000000000000000000000000000000000..76a4f49c836a838f80db7827dc9544d7b75a75c7 GIT binary patch literal 4510 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 ZYzLrIj|YHZ9{@rC{C|G;zkNCVe*ihcZYTf% literal 0 HcmV?d00001 diff --git a/lib/app/router.dart b/lib/app/router.dart index 9fc199e..6840c5d 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:kooltab2/views/settings_view.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import '../viewmodels/pin_lock_view_model.dart'; @@ -11,6 +10,9 @@ import '../views/history_screen_view.dart'; import '../views/pin_lock_view.dart'; import '../views/product_form_view.dart'; import '../views/product_list_view.dart'; +import '../views/settings_view.dart'; +import '../views/update_progress_view.dart'; +import '../utils/app_update_util.dart'; final RouteObserver routeObserver = RouteObserver(); @@ -80,6 +82,20 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel) { builder: (context, state) => const SettingsScreenView(), ), + GoRoute( + path: '/update-progress', + builder: (context, state) { + final update = state.extra as UpdateInfo; + final simulate = state.uri.queryParameters['simulate'] == 'true'; + final simulateError = state.uri.queryParameters['simulateError'] == 'true'; + return UpdateProgressView( + update: update, + simulate: simulate, + simulateError: simulateError, + ); + }, + ), + GoRoute( path: '/dev', builder: (context, state) => const DevMenuView(), diff --git a/lib/utils/app_updater.dart b/lib/utils/app_updater.dart index e2132ed..176dc0f 100644 --- a/lib/utils/app_updater.dart +++ b/lib/utils/app_updater.dart @@ -1,9 +1,8 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:kooltab2/utils/app_update_util.dart'; -import 'package:ota_update/ota_update.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; import 'app_config.dart'; @@ -43,7 +42,7 @@ class UpdateChecker { showDialog( context: context, barrierDismissible: !update.mandatory, - builder: (_) => AlertDialog( + builder: (dialogContext) => AlertDialog( title: const Text("Update available"), content: Column( mainAxisSize: MainAxisSize.min, @@ -57,14 +56,13 @@ class UpdateChecker { actions: [ if (!update.mandatory) TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => Navigator.pop(dialogContext), child: const Text("Later"), ), - FilledButton( - onPressed: () async { - Navigator.pop(context); - await _downloadAndInstall(update); + onPressed: () { + Navigator.pop(dialogContext); + context.push('/update-progress', extra: update); }, child: const Text("Update"), ), @@ -73,45 +71,3 @@ class UpdateChecker { ); } } - -Future _downloadAndInstall(UpdateInfo update) async { - try { - final url = update.download.startsWith("http") - ? update.download - : "$kUpdateServerUrl${update.download}"; - OtaUpdate() - .execute( - url, - destinationFilename: "update.apk", - sha256checksum: update.sha256, - ) - .listen((OtaEvent event) { - debugPrint("OTA status: ${event.status}"); - debugPrint("OTA value: ${event.value}"); - - switch (event.status) { - case OtaStatus.DOWNLOADING: - final progress = double.tryParse(event.value ?? "0") ?? 0; - debugPrint("Downloading ${progress.toStringAsFixed(0)}%"); - break; - case OtaStatus.INSTALLING: - debugPrint("Installing update"); - break; - case OtaStatus.INSTALLATION_ERROR: - debugPrint("Installation error: ${event.value}"); - break; - case OtaStatus.DOWNLOAD_ERROR: - debugPrint("Download error: ${event.value}"); - break; - default: - break; - } - }, onError: (e, stack) { - debugPrint("OTA stream error: $e"); - Sentry.captureException(e, stackTrace: stack); - }); - } catch (e, stack) { - debugPrint("OTA update failed: $e"); - Sentry.captureException(e, stackTrace: stack); - } -} diff --git a/lib/viewmodels/update_progress_view_model.dart b/lib/viewmodels/update_progress_view_model.dart new file mode 100644 index 0000000..979360c --- /dev/null +++ b/lib/viewmodels/update_progress_view_model.dart @@ -0,0 +1,181 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:ota_update/ota_update.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +import '../utils/app_config.dart'; +import '../utils/app_update_util.dart'; + +enum OtaPhase { downloading, installing, done, error } + +class UpdateProgressViewModel extends ChangeNotifier { + final UpdateInfo update; + final bool simulate; + final bool simulateError; + + OtaPhase _phase = OtaPhase.downloading; + double _progress = 0; + String _statusText = 'Preparing download…'; + String? _errorMessage; + bool _done = false; + + StreamSubscription? _subscription; + Timer? _simTimer; + + OtaPhase get phase => _phase; + double get progress => _progress; + String get statusText => _statusText; + String? get errorMessage => _errorMessage; + + UpdateProgressViewModel({ + required this.update, + this.simulate = false, + this.simulateError = false, + }); + + Future start() async { + if (simulate) { + _runSimulation(); + return; + } + + final url = update.download.startsWith('http') + ? update.download + : '$kUpdateServerUrl${update.download}'; + + debugPrint('UpdateProgressViewModel: starting download from $url'); + + _subscription = OtaUpdate() + .execute( + url, + destinationFilename: 'update.apk', + sha256checksum: update.sha256, + ) + .listen( + _onEvent, + onError: _onError, + onDone: _onDone, + ); + } + + void _runSimulation() { + debugPrint('UpdateProgressViewModel: running simulated download'); + + int step = 0; + const totalSteps = 20; + const stepDuration = Duration(milliseconds: 500); + + _simTimer = Timer.periodic(stepDuration, (timer) { + step++; + + if (simulateError && step == 10) { + timer.cancel(); + _phase = OtaPhase.error; + _errorMessage = 'Simulated download error (network timeout)'; + _statusText = 'Download failed'; + notifyListeners(); + return; + } + + if (step < totalSteps) { + _progress = step / totalSteps; + _statusText = 'Downloading ${(_progress * 100).toStringAsFixed(0)}%'; + _phase = OtaPhase.downloading; + notifyListeners(); + } else { + timer.cancel(); + _startInstallPhase(); + } + }); + } + + void _startInstallPhase() { + _phase = OtaPhase.installing; + _progress = 1.0; + _statusText = 'Installing update…'; + notifyListeners(); + + _simTimer = Timer(const Duration(seconds: 3), () { + _phase = OtaPhase.done; + _progress = 1.0; + _statusText = 'Update installed! Restarting…'; + notifyListeners(); + }); + } + + void _onDone() { + if (_done) return; + _done = true; + _phase = OtaPhase.done; + _progress = 1.0; + _statusText = 'Update installed! Restarting…'; + notifyListeners(); + } + + void _onEvent(OtaEvent event) { + switch (event.status) { + case OtaStatus.DOWNLOADING: + final raw = double.tryParse(event.value ?? '0') ?? 0; + _progress = (raw / 100).clamp(0.0, 1.0); + _statusText = 'Downloading ${_progress.toStringAsFixed(0)}%'; + _phase = OtaPhase.downloading; + notifyListeners(); + + case OtaStatus.INSTALLING: + _phase = OtaPhase.installing; + _progress = 1.0; + _statusText = 'Installing update…'; + notifyListeners(); + + case OtaStatus.INSTALLATION_DONE: + _onDone(); + return; + + case OtaStatus.DOWNLOAD_ERROR: + _phase = OtaPhase.error; + _errorMessage = 'Download failed: ${event.value}'; + _statusText = 'Download failed'; + Sentry.captureMessage('OTA download error: ${event.value}'); + notifyListeners(); + + case OtaStatus.INSTALLATION_ERROR: + _phase = OtaPhase.error; + _errorMessage = 'Installation failed: ${event.value}'; + _statusText = 'Installation failed'; + Sentry.captureMessage('OTA installation error: ${event.value}'); + notifyListeners(); + + case OtaStatus.ALREADY_RUNNING_ERROR: + _phase = OtaPhase.error; + _errorMessage = 'Update already in progress'; + _statusText = 'Update already running'; + notifyListeners(); + + default: + break; + } + } + + void _onError(Object error, StackTrace stack) { + debugPrint('UpdateProgressViewModel: stream error: $error'); + _phase = OtaPhase.error; + _errorMessage = error.toString(); + _statusText = 'Update failed'; + Sentry.captureException(error, stackTrace: stack); + notifyListeners(); + } + + void cancel() { + _simTimer?.cancel(); + _simTimer = null; + _subscription?.cancel(); + _subscription = null; + } + + @override + void dispose() { + cancel(); + super.dispose(); + } +} diff --git a/lib/views/dev_menu_view.dart b/lib/views/dev_menu_view.dart index 902208b..2041665 100644 --- a/lib/views/dev_menu_view.dart +++ b/lib/views/dev_menu_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import '../utils/app_update_util.dart'; import '../viewmodels/dev_menu_view_model.dart'; class DevMenuView extends StatefulWidget { @@ -128,9 +129,9 @@ class _DevMenuViewState extends State { children: [ _Tile( icon: Icons.update_rounded, - title: 'Mock update available', - subtitle: 'Simulate OTA update dialog', - onTap: () => _showMockUpdateDialog(), + title: 'Simulate update download', + subtitle: 'Open the download progress screen', + onTap: () => _showSimulateUpdateDialog(), ), ], ), @@ -209,28 +210,66 @@ class _DevMenuViewState extends State { ); } - void _showMockUpdateDialog() { + void _showSimulateUpdateDialog() { + final fakeUpdate = UpdateInfo( + update: true, + version: '99.0.0', + notes: 'Bug fixes and performance improvements.', + mandatory: false, + sha256: 'abc123', + download: 'https://example.com/fake-update.apk', + ); + showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('Update available'), - content: const Column( + builder: (dialogContext) => AlertDialog( + title: const Text('Simulate update'), + content: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Version 99.0.0 is available.'), - SizedBox(height: 12), - Text('Bug fixes and performance improvements.'), + const Text('Choose a simulation mode:'), + const SizedBox(height: 16), + _SimOption( + icon: Icons.download_rounded, + label: 'Successful download', + description: 'Progress 0→100%, then install, then done', + onTap: () { + Navigator.pop(dialogContext); + context.push( + '/update-progress?simulate=true', + extra: fakeUpdate, + ); + }, + ), + const SizedBox(height: 8), + _SimOption( + icon: Icons.error_outline_rounded, + label: 'Download error', + description: 'Fails at 50% with a network timeout', + onTap: () { + Navigator.pop(dialogContext); + context.push( + '/update-progress?simulate=true&simulateError=true', + extra: fakeUpdate, + ); + }, + ), + const SizedBox(height: 8), + _SimOption( + icon: Icons.wifi_off_rounded, + label: 'Real download (will fail)', + description: 'Attempts real OTA with fake URL', + onTap: () { + Navigator.pop(dialogContext); + context.push('/update-progress', extra: fakeUpdate); + }, + ), ], ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Later'), - ), - FilledButton( - onPressed: () => Navigator.pop(context), - child: const Text('Update'), + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), ), ], ), @@ -238,6 +277,56 @@ class _DevMenuViewState extends State { } } +class _SimOption extends StatelessWidget { + final IconData icon; + final String label; + final String description; + final VoidCallback onTap; + + const _SimOption({ + required this.icon, + required this.label, + required this.description, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon(icon, size: 28), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.titleSmall), + Text( + description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right_rounded), + ], + ), + ), + ), + ); + } +} + class _Section extends StatelessWidget { final String title; final List children; diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart index f202bac..8b90a94 100644 --- a/lib/views/settings_view.dart +++ b/lib/views/settings_view.dart @@ -344,7 +344,7 @@ class _SettingsScreenViewState extends State { showDialog( context: context, barrierDismissible: !update.mandatory, - builder: (context) => AlertDialog( + builder: (dialogContext) => AlertDialog( title: const Text("Update available"), content: Column( mainAxisSize: MainAxisSize.min, @@ -358,30 +358,13 @@ class _SettingsScreenViewState extends State { actions: [ if (!update.mandatory) TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => Navigator.pop(dialogContext), child: const Text("Later"), ), FilledButton( - onPressed: () async { - Navigator.pop(context); - - try { - await context.read().installUpdate( - update, - onProgress: (progress) { - debugPrint( - "Download ${(progress * 100).toStringAsFixed(0)}%", - ); - }, - ); - } catch (e, stack) { - Sentry.captureException(e, stackTrace: stack); - if (!mounted) return; - - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text("Update failed: $e"))); - } + onPressed: () { + Navigator.pop(dialogContext); + context.push('/update-progress', extra: update); }, child: const Text("Update"), ), diff --git a/lib/views/update_progress_view.dart b/lib/views/update_progress_view.dart new file mode 100644 index 0000000..155cbe3 --- /dev/null +++ b/lib/views/update_progress_view.dart @@ -0,0 +1,300 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../utils/app_update_util.dart'; +import '../viewmodels/update_progress_view_model.dart'; + +class UpdateProgressView extends StatefulWidget { + final UpdateInfo update; + final bool simulate; + final bool simulateError; + + const UpdateProgressView({ + super.key, + required this.update, + this.simulate = false, + this.simulateError = false, + }); + + @override + State createState() => _UpdateProgressViewState(); +} + +class _UpdateProgressViewState extends State { + late final UpdateProgressViewModel _vm; + + @override + void initState() { + super.initState(); + _vm = UpdateProgressViewModel( + update: widget.update, + simulate: widget.simulate, + simulateError: widget.simulateError, + ); + _vm.addListener(_onVmChange); + _vm.start(); + } + + void _onVmChange() { + if (!mounted) return; + + if (_vm.phase == OtaPhase.done) { + _showRestartDialog(); + } else if (_vm.phase == OtaPhase.error) { + _showErrorSnackBar(); + } + } + + void _showRestartDialog() { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: const Text('Update ready'), + content: const Text( + 'The update has been downloaded and installed. ' + 'Restart the app now to apply the changes.', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + this.context.go('/bar'); + }, + child: const Text('Later'), + ), + FilledButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Restart app'), + ), + ], + ), + ); + } + + void _showErrorSnackBar() { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_vm.errorMessage ?? 'Update failed'), + backgroundColor: Theme.of(context).colorScheme.error, + action: SnackBarAction( + label: 'Retry', + onPressed: () { + _vm.cancel(); + _vm.start(); + }, + ), + ), + ); + } + + @override + void dispose() { + _vm.removeListener(_onVmChange); + _vm.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => _showCancelDialog(), + ), + title: const Text('Updating'), + centerTitle: true, + automaticallyImplyLeading: false, + ), + body: ListenableBuilder( + listenable: _vm, + builder: (context, child) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(), + const SizedBox(height: 40), + _buildProgress(_vm), + const SizedBox(height: 24), + _buildStatus(_vm), + const Spacer(), + _buildVersionInfo(_vm), + ], + ), + ), + ), + ); + }, + ), + ); + } + + Widget _buildHeader() { + return Column( + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.system_update_alt_rounded, + size: 56, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 24), + Text( + 'KoolTab', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 8), + Text( + 'Version ${widget.update.version}', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ); + } + + Widget _buildProgress(UpdateProgressViewModel vm) { + final scheme = Theme.of(context).colorScheme; + + return Column( + children: [ + SizedBox( + width: 200, + height: 200, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox.expand( + child: CircularProgressIndicator( + value: vm.phase == OtaPhase.installing ? null : vm.progress, + strokeWidth: 10, + backgroundColor: scheme.primary.withValues(alpha: 0.12), + color: vm.phase == OtaPhase.error + ? scheme.error + : scheme.primary, + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (vm.phase == OtaPhase.error) + Icon( + Icons.error_outline_rounded, + size: 48, + color: scheme.error, + ) + else if (vm.phase == OtaPhase.installing) + Icon( + Icons.settings_rounded, + size: 48, + color: scheme.primary, + ) + else + Text( + '${(vm.progress * 100).toStringAsFixed(0)}%', + style: Theme.of(context).textTheme.headlineLarge, + ), + const SizedBox(height: 4), + Text( + vm.phase == OtaPhase.installing ? 'Installing' : '', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + if (vm.phase == OtaPhase.downloading) + LinearProgressIndicator( + value: vm.progress, + minHeight: 6, + borderRadius: BorderRadius.circular(999), + ), + ], + ); + } + + Widget _buildStatus(UpdateProgressViewModel vm) { + final scheme = Theme.of(context).colorScheme; + + final color = switch (vm.phase) { + OtaPhase.error => scheme.error, + OtaPhase.done => Colors.green, + _ => scheme.onSurface, + }; + + return Text( + vm.statusText, + style: Theme.of(context).textTheme.titleMedium?.copyWith(color: color), + textAlign: TextAlign.center, + ); + } + + Widget _buildVersionInfo(UpdateProgressViewModel vm) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.info_outline_rounded, + size: 14, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4), + ), + const SizedBox(width: 6), + Text( + 'Do not close the app during the update', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4), + ), + ), + ], + ); + } + + void _showCancelDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Cancel update?'), + content: const Text( + 'The update is in progress. If you leave now, ' + 'the app may become unstable.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Continue update'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + onPressed: () { + Navigator.pop(context); + _vm.cancel(); + this.context.go('/bar'); + }, + child: const Text('Cancel update'), + ), + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index d2803c0..19e095a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.3+1 +version: 1.0.7+1 environment: sdk: ^3.12.2