From 0bccc10f3b03dbf543e7a882c25015013f32aefd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 25 Oct 2026 15:04:26 +0300 Subject: [PATCH] fix(settings): a failed settings write is reported, not left to the zone handler (#1764) --- .../preferences_appearance_section.dart | 7 ++-- lib/features/settings/preferences_dialog.dart | 19 +++++----- .../settings/preferences_mcp_section.dart | 2 +- lib/shared/report_error.dart | 28 +++++++++++++++ test/shared/report_error_test.dart | 35 +++++++++++++++++++ 5 files changed, 78 insertions(+), 13 deletions(-) diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 1b681953..aa9fe1b8 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -1,3 +1,4 @@ +import 'package:querya_desktop/shared/report_error.dart'; import 'package:flutter/widgets.dart'; import 'dart:async' show unawaited; @@ -163,7 +164,7 @@ class _PreferencesAppearanceSectionState control: PreferencesDropdownMenu( value: c.themeMode, onSelected: (v) { - if (v != null) unawaited(_setThemeMode(v)); + if (v != null) runReported(context, () => _setThemeMode(v), where: 'PreferencesAppearanceSection', failedAction: 'The setting was not saved'); }, entries: const [ material.DropdownMenuEntry( @@ -189,7 +190,7 @@ class _PreferencesAppearanceSectionState selectedThemeId: c.effectiveSelectedThemeId, expandToParent: true, isLoading: refreshingThemes, - onSelected: (id) => unawaited(_setThemeById(id)), + onSelected: (id) => runReported(context, () => _setThemeById(id), where: 'PreferencesAppearanceSection', failedAction: 'The setting was not saved'), onPreviewTheme: _previewThemeById, ), ), @@ -234,7 +235,7 @@ class _PreferencesAppearanceSectionState subtitle: const Text( 'Smooth transitions when switching dark/light or presets. Requires Motion Full or Reduced.', ).muted().xSmall(), - onChanged: (v) => unawaited(_setThemeAnimation(v)), + onChanged: (v) => runReported(context, () => _setThemeAnimation(v), where: 'PreferencesAppearanceSection', failedAction: 'The setting was not saved'), ), const material.SizedBox(height: 12), PreferencesFieldRow( diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index ec66fd09..a9ed8831 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -1,3 +1,4 @@ +import 'package:querya_desktop/shared/report_error.dart'; import 'package:flutter/widgets.dart'; import 'dart:async'; @@ -566,7 +567,7 @@ class PreferencesDialogContentState subtitle: const Text( 'Silently checks GitHub Releases when Querya starts to notify you of new features.', ).muted().xSmall(), - onChanged: (v) => unawaited(_setCheckUpdatesOnStartup(v)), + onChanged: (v) => runReported(context, () => _setCheckUpdatesOnStartup(v), where: 'PreferencesDialog', failedAction: 'The setting was not saved'), ), const material.SizedBox(height: 16), PreferencesFieldRow( @@ -584,7 +585,7 @@ class PreferencesDialogContentState ? UpdateChannel.stable : _updateChannel, onSelected: (v) { - if (v != null) unawaited(_setUpdateChannel(v)); + if (v != null) runReported(context, () => _setUpdateChannel(v), where: 'PreferencesDialog', failedAction: 'The setting was not saved'); }, entries: const [ material.DropdownMenuEntry( @@ -617,7 +618,7 @@ class PreferencesDialogContentState control: PreferencesDropdownMenu( value: _fontSize, onSelected: (v) { - if (v != null) unawaited(_setFont(v)); + if (v != null) runReported(context, () => _setFont(v), where: 'PreferencesDialog', failedAction: 'The setting was not saved'); }, entries: const [ material.DropdownMenuEntry(value: 11.0, label: '11 pt'), @@ -637,7 +638,7 @@ class PreferencesDialogContentState control: SqlStatementTimeoutDropdown( value: _pgTimeout, expandToParent: true, - onChanged: (v) => unawaited(_setPg(v)), + onChanged: (v) => runReported(context, () => _setPg(v), where: 'PreferencesDialog', failedAction: 'The setting was not saved'), ), ), const material.SizedBox(height: 16), @@ -648,7 +649,7 @@ class PreferencesDialogContentState control: SqlStatementTimeoutDropdown( value: _mysqlTimeout, expandToParent: true, - onChanged: (v) => unawaited(_setMysql(v)), + onChanged: (v) => runReported(context, () => _setMysql(v), where: 'PreferencesDialog', failedAction: 'The setting was not saved'), ), ), const material.SizedBox(height: 16), @@ -659,7 +660,7 @@ class PreferencesDialogContentState control: SqlStatementTimeoutDropdown( value: _sqliteTimeout, expandToParent: true, - onChanged: (v) => unawaited(_setSqlite(v)), + onChanged: (v) => runReported(context, () => _setSqlite(v), where: 'PreferencesDialog', failedAction: 'The setting was not saved'), ), ), const material.SizedBox(height: 16), @@ -670,7 +671,7 @@ class PreferencesDialogContentState control: PreferencesDropdownMenu( value: _historyMax, onSelected: (v) { - if (v != null) unawaited(_setHistoryMax(v)); + if (v != null) runReported(context, () => _setHistoryMax(v), where: 'PreferencesDialog', failedAction: 'The setting was not saved'); }, entries: [ for (final n in kSqlHistoryMaxEntriesPresets) @@ -688,7 +689,7 @@ class PreferencesDialogContentState subtitle: const Text( 'Displays confirmation modal before running DROP, TRUNCATE, or unconditional DELETE queries.', ).muted().xSmall(), - onChanged: (v) => unawaited(_setConfirmDestructive(v)), + onChanged: (v) => runReported(context, () => _setConfirmDestructive(v), where: 'PreferencesDialog', failedAction: 'The setting was not saved'), ), ], ); @@ -711,7 +712,7 @@ class PreferencesDialogContentState control: PreferencesDropdownMenu( value: _maxRows, onSelected: (v) { - if (v != null) unawaited(_setMaxRows(v)); + if (v != null) runReported(context, () => _setMaxRows(v), where: 'PreferencesDialog', failedAction: 'The setting was not saved'); }, entries: [ for (final n in kSqlResultMaxRowsPresets) diff --git a/lib/features/settings/preferences_mcp_section.dart b/lib/features/settings/preferences_mcp_section.dart index fe4a6863..5ffc141f 100644 --- a/lib/features/settings/preferences_mcp_section.dart +++ b/lib/features/settings/preferences_mcp_section.dart @@ -278,7 +278,7 @@ class PreferencesMcpSectionState extends material.State { 'Production: the model can read all data this user can.', ).muted().xSmall() : null, - onChanged: (v) => unawaited(_setShared(c.id!, v)), + onChanged: (v) => runReported(context, () => _setShared(c.id!, v), where: 'PreferencesMcpSection', failedAction: 'The setting was not saved'), ), ], ); diff --git a/lib/shared/report_error.dart b/lib/shared/report_error.dart index ccc073c9..bd577afa 100644 --- a/lib/shared/report_error.dart +++ b/lib/shared/report_error.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/widgets.dart'; import 'package:querya_desktop/core/database/database_error_mapper.dart'; import 'package:querya_desktop/core/mcp/mcp_redaction.dart'; @@ -50,3 +52,29 @@ String failureMessage( QueryaLog.instance.error(where, action, error: error, stack: stack); return '$action: ${describeError(error, driver: driver)}'; } + +/// Starts [action] without waiting for it and reports its failure like +/// [reportError]. For a user action that is fired from a callback +/// (`onChanged: (v) => unawaited(_save(v))`), where a bare `unawaited` would +/// leave a failed write to the zone handler: the log only, with the control on +/// screen showing a value that was not saved. +void runReported( + BuildContext context, + Future Function() action, { + required String where, + required String failedAction, + DatabaseDriver? driver, +}) { + unawaited(() async { + try { + await action(); + } catch (error, stack) { + if (context.mounted) { + reportError(context, error, + where: where, action: failedAction, stack: stack, driver: driver); + } else { + QueryaLog.instance.error(where, failedAction, error: error, stack: stack); + } + } + }()); +} diff --git a/test/shared/report_error_test.dart b/test/shared/report_error_test.dart index 9c55a89b..7f6aa72e 100644 --- a/test/shared/report_error_test.dart +++ b/test/shared/report_error_test.dart @@ -85,4 +85,39 @@ void main() { await tester.pump(const Duration(seconds: 6)); }); + + testWidgets('runReported tells the user when the action fails', (tester) async { + final host = await pumpHost(tester); + + runReported( + host, + () async => throw StateError('disk full'), + where: 'Somewhere.save', + failedAction: 'The setting was not saved', + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect( + find.text('The setting was not saved. Bad state: disk full'), + findsOneWidget, + ); + await tester.pump(const Duration(seconds: 6)); + }); + + testWidgets('runReported is quiet when the action succeeds', (tester) async { + final host = await pumpHost(tester); + var ran = false; + + runReported( + host, + () async => ran = true, + where: 'Somewhere.save', + failedAction: 'The setting was not saved', + ); + await tester.pump(); + + expect(ran, isTrue); + expect(find.textContaining('not saved'), findsNothing); + }); }