diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 72dd0db6..dba02f01 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -116,6 +116,7 @@ abstract final class AppSettingsKeys { static const motionLevel = 'motion_level'; static const updateChannel = 'update_channel'; static const checkForUpdatesOnStartup = 'check_for_updates_on_startup'; + static const updateDismissedVersion = 'update_dismissed_version'; } /// Bumps [listenable] when any preference is persisted (theme, legacy listeners). @@ -589,4 +590,27 @@ class AppSettings { ); AppSettingsRevision.bump(); } + + /// Version the user dismissed via "Remind me later" (badge hidden until newer). + Future getUpdateDismissedVersion() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.updateDismissedVersion, + ); + if (v == null || v.isEmpty) return null; + return v; + } + + Future setUpdateDismissedVersion(String? version) async { + if (version == null || version.isEmpty) { + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.updateDismissedVersion, + ); + } else { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.updateDismissedVersion, + version, + ); + } + AppSettingsRevision.bump(); + } } diff --git a/lib/core/updater/app_updater_service.dart b/lib/core/updater/app_updater_service.dart index e4154993..3b78cb9b 100644 --- a/lib/core/updater/app_updater_service.dart +++ b/lib/core/updater/app_updater_service.dart @@ -112,6 +112,7 @@ class AppUpdaterService { UpdateAsset asset, { UpdateManifest? manifest, UpdateDownloadProgressCallback? onProgress, + bool Function()? shouldCancel, }) async { final checksums = await _resolveChecksums(asset: asset, manifest: manifest); final expected = checksums[asset.name] ?? asset.sha256; @@ -140,6 +141,9 @@ class AppUpdaterService { final sink = destination.openWrite(); try { await for (final chunk in response.stream) { + if (shouldCancel?.call() == true) { + throw const AppUpdaterException('Download cancelled'); + } received += chunk.length; sink.add(chunk); if (onProgress != null) { @@ -150,10 +154,25 @@ class AppUpdaterService { await sink.close(); } + if (shouldCancel?.call() == true) { + if (await destination.exists()) { + await destination.delete(); + } + throw const AppUpdaterException('Download cancelled'); + } + await verifyFileSha256(file: destination, expectedHex: expected); return destination; } + /// Phase 3 (#282) will replace this with native in-place installers. + Future installDownloadedUpdate(File verifiedPackage) async { + throw AppUpdaterException( + 'In-app installation is not available yet on this platform. ' + 'Verified package: ${verifiedPackage.path}', + ); + } + /// Picks the platform zip for the current OS from [manifest]. UpdateAsset? platformAssetFor(UpdateManifest manifest) { final suffix = switch (Platform.operatingSystem) { diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index 373b2cef..47ea78f8 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -7,6 +7,9 @@ import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/extensions/presentation/pages/extension_manager_dialog.dart'; import 'package:querya_desktop/features/help/about_dialog.dart'; +import 'package:querya_desktop/features/updater/update_available_badge.dart'; +import 'package:querya_desktop/features/updater/update_controller.dart'; +import 'package:querya_desktop/features/updater/update_dialog.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -229,6 +232,10 @@ class QueryaWindowTitleBar extends StatelessWidget { MenuButton( onPressed: (ctx) => showAboutDialog(ctx), child: const Text('About')), + MenuButton( + onPressed: (ctx) => showUpdateDialog(ctx), + child: const Text('Check for Updates…'), + ), MenuButton( onPressed: (_) => openQueryaDocumentation(), child: const Text('Documentation')), @@ -244,6 +251,7 @@ class QueryaWindowTitleBar extends StatelessWidget { Row( mainAxisSize: material.MainAxisSize.min, children: [ + UpdateAvailableBadge(controller: UpdateController.instance), MinimizeWindowButton(colors: buttonColors), MaximizeWindowButton(colors: buttonColors), CloseWindowButton(colors: closeButtonColors), diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 1db44f32..81f9768d 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -30,6 +30,7 @@ class _PreferencesDialogContent extends material.StatefulWidget { class _PreferencesDialogContentState extends material.State<_PreferencesDialogContent> { bool _loading = true; + bool _checkUpdatesOnStartup = true; int? _pgTimeout; int? _mysqlTimeout; int _maxRows = kDefaultSqlResultMaxRows; @@ -43,6 +44,7 @@ class _PreferencesDialogContentState } Future _load() async { + final startup = await AppSettings.instance.getCheckForUpdatesOnStartup(); final pg = await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(); final my = await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(); final rows = await AppSettings.instance.getSqlResultMaxRows(); @@ -50,6 +52,7 @@ class _PreferencesDialogContentState final font = await AppSettings.instance.getSqlEditorFontSize(); if (!mounted) return; setState(() { + _checkUpdatesOnStartup = startup; _pgTimeout = pg; _mysqlTimeout = my; _maxRows = rows; @@ -59,6 +62,11 @@ class _PreferencesDialogContentState }); } + Future _setCheckUpdatesOnStartup(bool enabled) async { + setState(() => _checkUpdatesOnStartup = enabled); + await AppSettings.instance.setCheckForUpdatesOnStartup(enabled); + } + Future _setPg(int? v) async { setState(() => _pgTimeout = v); await AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(v); @@ -138,6 +146,29 @@ class _PreferencesDialogContentState crossAxisAlignment: material.CrossAxisAlignment.start, children: [ + const Text('General') + .semiBold() + .small() + .foreground(), + const material.SizedBox(height: 8), + material.CheckboxListTile( + contentPadding: material.EdgeInsets.zero, + controlAffinity: + material.ListTileControlAffinity.leading, + title: const Text( + 'Automatically check for updates on startup', + ).small(), + subtitle: const Text( + 'Queries GitHub Releases silently when Querya starts.', + ).muted().xSmall(), + value: _checkUpdatesOnStartup, + onChanged: (v) { + if (v != null) { + unawaited(_setCheckUpdatesOnStartup(v)); + } + }, + ), + const material.SizedBox(height: 24), const PreferencesAppearanceSection(), const material.SizedBox(height: 24), const Text('SQL — PostgreSQL') diff --git a/lib/features/updater/update_available_badge.dart b/lib/features/updater/update_available_badge.dart new file mode 100644 index 00000000..f69bdc04 --- /dev/null +++ b/lib/features/updater/update_available_badge.dart @@ -0,0 +1,108 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/features/updater/update_controller.dart'; +import 'package:querya_desktop/features/updater/update_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Pulsing title-bar chip when a background update check finds a newer release. +class UpdateAvailableBadge extends material.StatefulWidget { + const UpdateAvailableBadge({super.key, required this.controller}); + + final UpdateController controller; + + @override + material.State createState() => + _UpdateAvailableBadgeState(); +} + +class _UpdateAvailableBadgeState extends material.State + with material.SingleTickerProviderStateMixin { + late final material.AnimationController _pulse; + + @override + void initState() { + super.initState(); + _pulse = material.AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + )..repeat(reverse: true); + widget.controller.addListener(_onControllerChanged); + } + + @override + void didUpdateWidget(covariant UpdateAvailableBadge oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onControllerChanged); + widget.controller.addListener(_onControllerChanged); + } + } + + void _onControllerChanged() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerChanged); + _pulse.dispose(); + super.dispose(); + } + + @override + material.Widget build(material.BuildContext context) { + if (!widget.controller.showBadge) { + return const material.SizedBox.shrink(); + } + + final version = widget.controller.pendingUpdate?.version ?? ''; + final wb = context.workbench; + + return material.Padding( + padding: const material.EdgeInsets.only(right: 8), + child: material.Material( + color: material.Colors.transparent, + child: material.InkWell( + borderRadius: material.BorderRadius.circular(999), + onTap: () => unawaited( + showUpdateDialog( + context, + initialManifest: widget.controller.pendingUpdate, + ), + ), + child: material.AnimatedBuilder( + animation: _pulse, + builder: (context, child) { + return material.Container( + padding: + const material.EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: material.BoxDecoration( + color: wb.accent.withValues(alpha: 0.12 + 0.08 * _pulse.value), + borderRadius: material.BorderRadius.circular(999), + border: material.Border.all( + color: wb.accent.withValues(alpha: 0.35 + 0.25 * _pulse.value), + ), + ), + child: child, + ); + }, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.card_giftcard_rounded, + size: 14, + color: wb.accent, + ), + const material.SizedBox(width: 6), + Text('v$version available').xSmall().semiBold(), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/updater/update_changelog_view.dart b/lib/features/updater/update_changelog_view.dart new file mode 100644 index 00000000..85930378 --- /dev/null +++ b/lib/features/updater/update_changelog_view.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Lightweight Markdown-ish renderer for GitHub release notes. +class UpdateChangelogView extends StatelessWidget { + const UpdateChangelogView({super.key, required this.markdown}); + + final String markdown; + + @override + material.Widget build(material.BuildContext context) { + if (markdown.trim().isEmpty) { + return const Text('No release notes provided.').muted().small(); + } + + final lines = const LineSplitter().convert(markdown); + final children = []; + + for (final line in lines) { + if (line.trim().isEmpty) { + children.add(const material.SizedBox(height: 8)); + continue; + } + + final trimmed = line.trimLeft(); + if (trimmed.startsWith('### ')) { + children.add( + material.Padding( + padding: const material.EdgeInsets.only(top: 8, bottom: 4), + child: Text(trimmed.substring(4)).semiBold().small(), + ), + ); + continue; + } + if (trimmed.startsWith('## ')) { + children.add( + material.Padding( + padding: const material.EdgeInsets.only(top: 10, bottom: 4), + child: Text(trimmed.substring(3)).semiBold(), + ), + ); + continue; + } + if (trimmed.startsWith('# ')) { + children.add( + material.Padding( + padding: const material.EdgeInsets.only(top: 12, bottom: 6), + child: Text(trimmed.substring(2)).semiBold().large(), + ), + ); + continue; + } + if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) { + children.add( + material.Padding( + padding: const material.EdgeInsets.only(left: 8, bottom: 4), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('• ').muted(), + material.Expanded( + child: Text(_inlineMarkdown(trimmed.substring(2))).small(), + ), + ], + ), + ), + ); + continue; + } + + children.add( + material.Padding( + padding: const material.EdgeInsets.only(bottom: 4), + child: Text(_inlineMarkdown(line)).small(), + ), + ); + } + + if (children.isEmpty) { + return const Text('No release notes provided.').muted().small(); + } + + return material.SelectionArea( + child: material.DefaultTextStyle( + style: material.TextStyle( + color: Theme.of(context).colorScheme.foreground, + height: 1.45, + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: children, + ), + ), + ); + } + + static String _inlineMarkdown(String input) { + return input.replaceAllMapped( + RegExp(r'`([^`]+)`'), + (match) => match.group(1) ?? '', + ); + } +} diff --git a/lib/features/updater/update_controller.dart b/lib/features/updater/update_controller.dart new file mode 100644 index 00000000..a0de1015 --- /dev/null +++ b/lib/features/updater/update_controller.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; + +import '../../core/storage/app_settings.dart'; +import '../../core/updater/app_updater_service.dart'; +import '../../core/updater/update_manifest.dart'; + +/// Tracks a pending update for the title-bar badge and startup background checks. +class UpdateController extends ChangeNotifier { + UpdateController({ + AppUpdaterService? updater, + AppSettings? settings, + }) : _updater = updater ?? AppUpdaterService.instance, + _settings = settings ?? AppSettings.instance; + + final AppUpdaterService _updater; + final AppSettings _settings; + + static final UpdateController instance = UpdateController(); + + UpdateManifest? _pendingUpdate; + String? _dismissedVersion; + bool _initialized = false; + + UpdateManifest? get pendingUpdate => _pendingUpdate; + + bool get showBadge => + _pendingUpdate != null && _pendingUpdate!.version != _dismissedVersion; + + Future initialize() async { + if (_initialized) return; + _initialized = true; + _dismissedVersion = await _settings.getUpdateDismissedVersion(); + final result = await _updater.maybeCheckOnStartup(); + if (result?.hasUpdate == true && result!.availableUpdate != null) { + _pendingUpdate = result.availableUpdate; + notifyListeners(); + } + } + + void setPendingUpdate(UpdateManifest? manifest) { + _pendingUpdate = manifest; + notifyListeners(); + } + + Future remindLater() async { + final version = _pendingUpdate?.version; + if (version == null || version.isEmpty) return; + _dismissedVersion = version; + await _settings.setUpdateDismissedVersion(version); + notifyListeners(); + } + + @visibleForTesting + void setDismissedVersionForTest(String? version) { + _dismissedVersion = version; + notifyListeners(); + } + + @visibleForTesting + void resetForTest() { + _pendingUpdate = null; + _dismissedVersion = null; + _initialized = false; + } +} diff --git a/lib/features/updater/update_dialog.dart b/lib/features/updater/update_dialog.dart new file mode 100644 index 00000000..eebfb7f6 --- /dev/null +++ b/lib/features/updater/update_dialog.dart @@ -0,0 +1,445 @@ +import 'dart:async' show unawaited; +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/updater/app_updater_service.dart'; +import 'package:querya_desktop/core/updater/update_manifest.dart'; +import 'package:querya_desktop/features/updater/update_changelog_view.dart'; +import 'package:querya_desktop/features/updater/update_controller.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +enum UpdateDialogPhase { + checking, + upToDate, + available, + downloading, + readyToInstall, + error, +} + +/// Shows the update check / download dialog. +Future showUpdateDialog( + material.BuildContext context, { + UpdateManifest? initialManifest, +}) { + return showAppDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(ctx), + child: _UpdateDialogContent(initialManifest: initialManifest), + ), + ); +} + +class _UpdateDialogContent extends material.StatefulWidget { + const _UpdateDialogContent({this.initialManifest}); + + final UpdateManifest? initialManifest; + + @override + material.State<_UpdateDialogContent> createState() => + _UpdateDialogContentState(); +} + +class _UpdateDialogContentState extends material.State<_UpdateDialogContent> { + final _updater = AppUpdaterService.instance; + final _controller = UpdateController.instance; + + UpdateDialogPhase _phase = UpdateDialogPhase.checking; + String _currentVersion = ''; + UpdateManifest? _manifest; + String? _errorMessage; + File? _downloadedFile; + + int _receivedBytes = 0; + int _totalBytes = 0; + double _bytesPerSecond = 0; + bool _downloadCancelled = false; + DateTime? _downloadStarted; + DateTime? _lastProgressAt; + int _lastProgressBytes = 0; + + @override + void initState() { + super.initState(); + if (widget.initialManifest != null) { + _manifest = widget.initialManifest; + _phase = UpdateDialogPhase.available; + } else { + unawaited(_runCheck()); + } + } + + Future _runCheck() async { + setState(() { + _phase = UpdateDialogPhase.checking; + _errorMessage = null; + }); + + try { + final result = await _updater.checkForUpdates(); + if (!mounted) return; + _currentVersion = result.currentVersion; + if (result.hasUpdate && result.availableUpdate != null) { + _manifest = result.availableUpdate; + _controller.setPendingUpdate(_manifest); + setState(() => _phase = UpdateDialogPhase.available); + } else { + setState(() => _phase = UpdateDialogPhase.upToDate); + } + } on AppUpdaterException catch (e) { + if (!mounted) return; + setState(() { + _phase = UpdateDialogPhase.error; + _errorMessage = e.message; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _phase = UpdateDialogPhase.error; + _errorMessage = e.toString(); + }); + } + } + + Future _startDownload() async { + final manifest = _manifest; + if (manifest == null) return; + + final asset = _updater.platformAssetFor(manifest); + if (asset == null) { + setState(() { + _phase = UpdateDialogPhase.error; + _errorMessage = + 'No update package found for ${Platform.operatingSystem}.'; + }); + return; + } + + setState(() { + _phase = UpdateDialogPhase.downloading; + _downloadCancelled = false; + _receivedBytes = 0; + _totalBytes = asset.sizeBytes ?? 0; + _bytesPerSecond = 0; + _downloadStarted = DateTime.now(); + _lastProgressAt = _downloadStarted; + _lastProgressBytes = 0; + _errorMessage = null; + }); + + try { + final file = await _updater.downloadAsset( + asset, + manifest: manifest, + shouldCancel: () => _downloadCancelled, + onProgress: (received, total) { + if (!mounted) return; + final now = DateTime.now(); + final elapsedMs = + now.difference(_lastProgressAt ?? now).inMilliseconds; + if (elapsedMs >= 250) { + final deltaBytes = received - _lastProgressBytes; + _bytesPerSecond = deltaBytes / (elapsedMs / 1000); + _lastProgressAt = now; + _lastProgressBytes = received; + } + setState(() { + _receivedBytes = received; + if (total > 0) _totalBytes = total; + }); + }, + ); + if (!mounted) return; + setState(() { + _downloadedFile = file; + _phase = UpdateDialogPhase.readyToInstall; + }); + } on AppUpdaterException catch (e) { + if (!mounted) return; + if (e.message == 'Download cancelled') { + setState(() => _phase = UpdateDialogPhase.available); + return; + } + setState(() { + _phase = UpdateDialogPhase.error; + _errorMessage = e.message; + }); + } + } + + Future _install() async { + final file = _downloadedFile; + if (file == null) return; + try { + await _updater.installDownloadedUpdate(file); + } on AppUpdaterException catch (e) { + if (!mounted) return; + setState(() { + _phase = UpdateDialogPhase.error; + _errorMessage = e.message; + }); + } + } + + Future _remindLater() async { + await _controller.remindLater(); + if (mounted) material.Navigator.of(context).pop(); + } + + String _formatBytes(int bytes) { + if (bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + var value = bytes.toDouble(); + var unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return '${value.toStringAsFixed(unit == 0 ? 0 : 1)} ${units[unit]}'; + } + + String? _releaseDateLabel(DateTime? date) { + if (date == null) return null; + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + final local = date.toLocal(); + return '${months[local.month - 1]} ${local.day}, ${local.year}'; + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + final wb = context.workbench; + + return material.Container( + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 520, + minWidth: 360, + maxHeight: 640, + ), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Row( + children: [ + material.Icon( + material.Icons.system_update_alt_rounded, + color: wb.accent, + ), + const material.SizedBox(width: 10), + const Text('Software Update').large().semiBold(), + ], + ), + const material.SizedBox(height: 8), + Text(_subtitle()).muted().small(), + ], + ), + ), + material.Flexible( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, + vertical: 8, + ), + child: _body(context), + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3), + ), + ), + ), + child: _actions(context), + ), + ], + ), + ), + ); + } + + String _subtitle() { + return switch (_phase) { + UpdateDialogPhase.checking => 'Checking for updates…', + UpdateDialogPhase.upToDate => + 'You are running the latest version of Querya Desktop (v$_currentVersion).', + UpdateDialogPhase.available => + 'Querya Desktop v${_manifest?.version ?? ''} is available!', + UpdateDialogPhase.downloading => 'Downloading update…', + UpdateDialogPhase.readyToInstall => 'Update ready to install.', + UpdateDialogPhase.error => 'Update check failed.', + }; + } + + material.Widget _body(material.BuildContext context) { + return switch (_phase) { + UpdateDialogPhase.checking => const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(32), + child: material.CircularProgressIndicator(), + ), + ), + UpdateDialogPhase.upToDate => material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 16), + child: const Text( + 'No newer release was found on the selected update channel.', + ).muted().small(), + ), + UpdateDialogPhase.available || + UpdateDialogPhase.downloading || + UpdateDialogPhase.readyToInstall => + _releaseBody(context), + UpdateDialogPhase.error => material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 16), + child: Text(_errorMessage ?? 'Unknown error').small(), + ), + }; + } + + material.Widget _releaseBody(material.BuildContext context) { + final manifest = _manifest; + if (manifest == null) return const material.SizedBox.shrink(); + + final dateLabel = _releaseDateLabel(manifest.releaseDate); + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + if (dateLabel != null) ...[ + Text('Released $dateLabel').muted().xSmall(), + const material.SizedBox(height: 12), + ], + if (_phase == UpdateDialogPhase.downloading) ...[ + material.LinearProgressIndicator( + value: _totalBytes > 0 ? _receivedBytes / _totalBytes : null, + ), + const material.SizedBox(height: 8), + Text( + '${_formatBytes(_receivedBytes)}' + '${_totalBytes > 0 ? ' / ${_formatBytes(_totalBytes)}' : ''}' + '${_bytesPerSecond > 0 ? ' · ${_formatBytes(_bytesPerSecond.round())}/s' : ''}', + ).muted().xSmall(), + const material.SizedBox(height: 16), + ], + if (manifest.changelog.isNotEmpty) ...[ + const Text('Release notes').semiBold().small(), + const material.SizedBox(height: 8), + material.Container( + constraints: const material.BoxConstraints(maxHeight: 280), + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: context.workbench.surface.withValues(alpha: 0.55), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: context.workbench.borderSubtle.withValues(alpha: 0.6), + ), + ), + child: material.SingleChildScrollView( + child: UpdateChangelogView(markdown: manifest.changelog), + ), + ), + ], + ], + ); + } + + material.Widget _actions(material.BuildContext context) { + return material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + ...switch (_phase) { + UpdateDialogPhase.checking => [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + UpdateDialogPhase.upToDate => [ + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + UpdateDialogPhase.available => [ + GhostButton( + onPressed: () => unawaited(_remindLater()), + child: const Text('Remind me later'), + ), + const material.SizedBox(width: 8), + PrimaryButton( + onPressed: () => unawaited(_startDownload()), + child: const Text('Download & Install'), + ), + ], + UpdateDialogPhase.downloading => [ + GhostButton( + onPressed: () { + setState(() => _downloadCancelled = true); + }, + child: const Text('Cancel'), + ), + ], + UpdateDialogPhase.readyToInstall => [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + const material.SizedBox(width: 8), + PrimaryButton( + onPressed: () => unawaited(_install()), + child: const Text('Restart & Update Now'), + ), + ], + UpdateDialogPhase.error => [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + const material.SizedBox(width: 8), + PrimaryButton( + onPressed: () => unawaited(_runCheck()), + child: const Text('Retry'), + ), + ], + }, + ], + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 7dc35aaf..3d3114bc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'core/motion/display_refresh_service.dart'; import 'core/motion/querya_motion_controller.dart'; import 'core/storage/local_db.dart'; import 'core/theme/theme_controller.dart'; +import 'features/updater/update_controller.dart'; void main() async { runZonedGuarded(() async { @@ -27,6 +28,7 @@ void main() async { await ThemeController.instance.load(); await UiScaleController.instance.load(); await QueryaMotionController.instance.load(); + unawaited(UpdateController.instance.initialize()); runApp(const QueryaApp()); doWhenWindowReady(() { final win = appWindow; diff --git a/test/features/updater/update_dialog_test.dart b/test/features/updater/update_dialog_test.dart new file mode 100644 index 00000000..fe684030 --- /dev/null +++ b/test/features/updater/update_dialog_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/updater/update_manifest.dart'; +import 'package:querya_desktop/features/updater/update_changelog_view.dart'; +import 'package:querya_desktop/features/updater/update_controller.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('UpdateChangelogView', () { + testWidgets('renders markdown headings and bullet lists', (tester) async { + const markdown = ''' +## Release 0.5.0 +- Faster CSV export +- SSL certificate UI +'''; + + await tester.pumpWidget( + queryaThemeTestShell( + child: const UpdateChangelogView(markdown: markdown), + ), + ); + + expect(find.text('Release 0.5.0'), findsOneWidget); + expect(find.textContaining('Faster CSV export'), findsOneWidget); + expect(find.textContaining('SSL certificate UI'), findsOneWidget); + }); + + testWidgets('shows fallback when changelog is empty', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const UpdateChangelogView(markdown: ' '), + ), + ); + + expect(find.text('No release notes provided.'), findsOneWidget); + }); + }); + + group('UpdateController', () { + test('showBadge reflects pending update', () { + final controller = UpdateController(); + controller.resetForTest(); + expect(controller.showBadge, isFalse); + + controller.setPendingUpdate( + const UpdateManifest( + version: '0.5.0', + changelog: '', + assets: [], + ), + ); + expect(controller.showBadge, isTrue); + + controller.setDismissedVersionForTest('0.5.0'); + expect(controller.showBadge, isFalse); + }); + }); +}