Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions lib/core/storage/app_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -589,4 +590,27 @@ class AppSettings {
);
AppSettingsRevision.bump();
}

/// Version the user dismissed via "Remind me later" (badge hidden until newer).
Future<String?> getUpdateDismissedVersion() async {
final v = await LocalDb.instance.getAppSetting(
AppSettingsKeys.updateDismissedVersion,
);
if (v == null || v.isEmpty) return null;
return v;
}

Future<void> 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();
}
}
19 changes: 19 additions & 0 deletions lib/core/updater/app_updater_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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<void> 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) {
Expand Down
8 changes: 8 additions & 0 deletions lib/features/main_screen/querya_window_title_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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')),
Expand All @@ -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),
Expand Down
31 changes: 31 additions & 0 deletions lib/features/settings/preferences_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -43,13 +44,15 @@ class _PreferencesDialogContentState
}

Future<void> _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();
final hist = await AppSettings.instance.getSqlHistoryMaxEntries();
final font = await AppSettings.instance.getSqlEditorFontSize();
if (!mounted) return;
setState(() {
_checkUpdatesOnStartup = startup;
_pgTimeout = pg;
_mysqlTimeout = my;
_maxRows = rows;
Expand All @@ -59,6 +62,11 @@ class _PreferencesDialogContentState
});
}

Future<void> _setCheckUpdatesOnStartup(bool enabled) async {
setState(() => _checkUpdatesOnStartup = enabled);
await AppSettings.instance.setCheckForUpdatesOnStartup(enabled);
}

Future<void> _setPg(int? v) async {
setState(() => _pgTimeout = v);
await AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(v);
Expand Down Expand Up @@ -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')
Expand Down
108 changes: 108 additions & 0 deletions lib/features/updater/update_available_badge.dart
Original file line number Diff line number Diff line change
@@ -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<UpdateAvailableBadge> createState() =>
_UpdateAvailableBadgeState();
}

class _UpdateAvailableBadgeState extends material.State<UpdateAvailableBadge>
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(),
],
),
),
),
),
);
}
}
105 changes: 105 additions & 0 deletions lib/features/updater/update_changelog_view.dart
Original file line number Diff line number Diff line change
@@ -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 = <material.Widget>[];

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) ?? '',
);
}
}
Loading
Loading