diff --git a/assets/products/duvel.png b/assets/products/duvel.png new file mode 100644 index 0000000..76a4f49 Binary files /dev/null and b/assets/products/duvel.png differ diff --git a/assets/products/koffie.jpg b/assets/products/koffie.jpg new file mode 100644 index 0000000..a911c36 Binary files /dev/null and b/assets/products/koffie.jpg differ 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