From 3d583cba12f5c3131bd10b548e2c81541d544aa4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 9 Jun 2026 13:08:11 +0300 Subject: [PATCH 1/5] feat(ui): apply dropdown design system tokens to QueryaDropdown (#89) Centralize trigger/menu metrics in QueryaDropdownTokens and restyle trigger, menu panel, hover states, and selected checkmarks across all dropdown call sites. --- lib/shared/widgets/querya_dropdown.dart | 297 +++++++++++++----- .../widgets/querya_dropdown_tokens.dart | 46 +++ lib/shared/widgets/widgets.dart | 2 +- test/shared/querya_dropdown_test.dart | 39 ++- 4 files changed, 307 insertions(+), 77 deletions(-) create mode 100644 lib/shared/widgets/querya_dropdown_tokens.dart diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index d6723e79..843bf8b0 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +export 'querya_dropdown_tokens.dart'; + /// One selectable row in [QueryaDropdown]. class QueryaDropdownItem { const QueryaDropdownItem({ @@ -18,7 +21,7 @@ class QueryaDropdownItem { /// Stable dropdown built on [material.MenuAnchor] (no Overlay portal). /// -/// Styling uses shadcn [ColorScheme] tokens from [Theme.of]. +/// Visual metrics: [QueryaDropdownTokens]. Colors from shadcn [ColorScheme]. class QueryaDropdown extends material.StatefulWidget { const QueryaDropdown({ super.key, @@ -29,8 +32,8 @@ class QueryaDropdown extends material.StatefulWidget { this.enabled = true, this.width, this.expandToParent = false, - this.alignmentOffset = const material.Offset(0, 6), - this.menuMaxHeight = 320, + this.alignmentOffset = QueryaDropdownTokens.menuAlignmentOffset, + this.menuMaxHeight = QueryaDropdownTokens.menuMaxHeight, this.hint, }); @@ -51,6 +54,7 @@ class QueryaDropdown extends material.StatefulWidget { class _QueryaDropdownState extends material.State> { late material.MenuController _controller; + bool _triggerHovered = false; @override void initState() { @@ -66,7 +70,7 @@ class _QueryaDropdownState extends material.State> { } } - material.Widget _triggerLabel({ + material.Widget _triggerLabelText({ required String label, required ColorScheme cs, required bool expand, @@ -76,8 +80,9 @@ class _QueryaDropdownState extends material.State> { maxLines: 1, overflow: material.TextOverflow.ellipsis, style: material.TextStyle( - fontSize: 14, - color: widget.enabled ? cs.popoverForeground : cs.mutedForeground, + fontSize: QueryaDropdownTokens.fontSize, + fontWeight: material.FontWeight.w500, + color: widget.enabled ? cs.foreground : cs.mutedForeground, ), ); if (expand) { @@ -94,31 +99,88 @@ class _QueryaDropdownState extends material.State> { } material.Widget _menuItem(QueryaDropdownItem item, ColorScheme cs) { - final selected = item.value == widget.value; - return material.MenuItemButton( - style: material.MenuItemButton.styleFrom( - minimumSize: const material.Size(180, 36), - padding: const material.EdgeInsets.symmetric(horizontal: 12), - backgroundColor: selected ? cs.muted.withValues(alpha: 0.45) : null, - foregroundColor: cs.popoverForeground, - disabledForegroundColor: cs.mutedForeground.withValues(alpha: 0.5), - shape: material.RoundedRectangleBorder( - borderRadius: material.BorderRadius.circular(6), + return _QueryaDropdownMenuItem( + item: item, + selected: item.value == widget.value, + enabled: widget.enabled && item.enabled, + colorScheme: cs, + onPick: () { + widget.onSelected(item.value); + _controller.close(); + }, + ); + } + + material.Widget _buildTrigger({ + required material.BuildContext context, + required material.MenuController controller, + required ColorScheme cs, + required String label, + required double? fieldWidth, + }) { + final borderColor = widget.enabled + ? (_triggerHovered ? cs.ring : cs.border) + : cs.border.withValues(alpha: 0.4); + + final triggerBody = material.MouseRegion( + cursor: widget.enabled + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + onEnter: widget.enabled ? (_) => setState(() => _triggerHovered = true) : null, + onExit: widget.enabled ? (_) => setState(() => _triggerHovered = false) : null, + child: material.AnimatedContainer( + duration: const Duration( + milliseconds: QueryaDropdownTokens.hoverAnimationMs, + ), + curve: material.Curves.easeOut, + height: QueryaDropdownTokens.triggerHeight, + padding: QueryaDropdownTokens.triggerPadding, + decoration: material.BoxDecoration( + color: _triggerHovered + ? cs.muted.withValues(alpha: 0.22) + : cs.muted.withValues(alpha: 0.08), + borderRadius: material.BorderRadius.circular( + QueryaDropdownTokens.menuBorderRadius, + ), + border: material.Border.all(color: borderColor), + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + mainAxisSize: + widget.expandToParent ? material.MainAxisSize.max : material.MainAxisSize.min, + children: [ + _triggerLabelText(label: label, cs: cs, expand: widget.expandToParent), + const material.SizedBox(width: QueryaDropdownTokens.triggerChevronGap), + material.Icon( + material.Icons.keyboard_arrow_down_rounded, + size: QueryaDropdownTokens.triggerChevronSize, + color: widget.enabled + ? cs.mutedForeground + : cs.mutedForeground.withValues(alpha: 0.5), + ), + ], ), ), - onPressed: !widget.enabled || !item.enabled - ? null - : () { - widget.onSelected(item.value); - _controller.close(); - }, - leadingIcon: item.leading, - child: material.Text( - item.label, - style: material.TextStyle( - fontSize: 14, - fontWeight: selected ? material.FontWeight.w600 : material.FontWeight.w400, + ); + + return material.Material( + type: material.MaterialType.transparency, + child: material.InkWell( + onTap: widget.enabled + ? () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + } + : null, + borderRadius: material.BorderRadius.circular( + QueryaDropdownTokens.menuBorderRadius, ), + child: fieldWidth != null + ? material.SizedBox(width: fieldWidth, child: triggerBody) + : triggerBody, ), ); } @@ -126,11 +188,12 @@ class _QueryaDropdownState extends material.State> { @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusMd; final label = _labelFor(widget.value); final fieldWidth = widget.expandToParent ? null : widget.width; - final menuChildren = widget.items.map((item) => _menuItem(item, cs)).toList(); + final effectiveMaxHeight = widget.items.length > QueryaDropdownTokens.menuScrollItemThreshold + ? widget.menuMaxHeight + : double.infinity; final anchor = material.MenuAnchor( controller: _controller, @@ -139,66 +202,40 @@ class _QueryaDropdownState extends material.State> { style: material.MenuStyle( backgroundColor: material.WidgetStatePropertyAll(cs.popover), surfaceTintColor: material.WidgetStatePropertyAll(cs.popover), - elevation: const material.WidgetStatePropertyAll(8), + elevation: const material.WidgetStatePropertyAll( + QueryaDropdownTokens.menuElevation, + ), + shadowColor: const material.WidgetStatePropertyAll( + QueryaDropdownTokens.menuShadowColor, + ), maximumSize: material.WidgetStatePropertyAll( - material.Size(double.infinity, widget.menuMaxHeight), + material.Size(double.infinity, effectiveMaxHeight), ), padding: const material.WidgetStatePropertyAll( - material.EdgeInsets.symmetric(vertical: 4, horizontal: 4), + QueryaDropdownTokens.menuPadding, ), shape: material.WidgetStatePropertyAll( material.RoundedRectangleBorder( - borderRadius: material.BorderRadius.circular(radius), + borderRadius: material.BorderRadius.circular( + QueryaDropdownTokens.menuBorderRadius, + ), side: material.BorderSide(color: cs.border), ), ), ), menuChildren: menuChildren, builder: (context, controller, child) { - final field = material.InkWell( - onTap: widget.enabled - ? () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - } - : null, - borderRadius: material.BorderRadius.circular(6), - child: material.Container( - width: fieldWidth, - padding: const material.EdgeInsets.symmetric(horizontal: 4, vertical: 6), - decoration: material.BoxDecoration( - border: material.Border( - bottom: material.BorderSide( - color: widget.enabled ? cs.border : cs.border.withValues(alpha: 0.4), - ), - ), - ), - child: material.Row( - mainAxisSize: - widget.expandToParent ? material.MainAxisSize.max : material.MainAxisSize.min, - children: [ - _triggerLabel( - label: label, - cs: cs, - expand: widget.expandToParent, - ), - material.Icon( - material.Icons.arrow_drop_down_rounded, - size: 22, - color: widget.enabled ? cs.mutedForeground : cs.mutedForeground.withValues(alpha: 0.5), - ), - ], - ), - ), + final trigger = _buildTrigger( + context: context, + controller: controller, + cs: cs, + label: label, + fieldWidth: fieldWidth, ); - if (widget.expandToParent) { - return material.SizedBox(width: double.infinity, child: field); + return material.SizedBox(width: double.infinity, child: trigger); } - return field; + return trigger; }, ); @@ -208,3 +245,113 @@ class _QueryaDropdownState extends material.State> { return anchor; } } + +class _QueryaDropdownMenuItem extends material.StatefulWidget { + const _QueryaDropdownMenuItem({ + required this.item, + required this.selected, + required this.enabled, + required this.colorScheme, + required this.onPick, + }); + + final QueryaDropdownItem item; + final bool selected; + final bool enabled; + final ColorScheme colorScheme; + final material.VoidCallback onPick; + + @override + material.State<_QueryaDropdownMenuItem> createState() => + _QueryaDropdownMenuItemState(); +} + +class _QueryaDropdownMenuItemState extends material.State<_QueryaDropdownMenuItem> { + bool _hovered = false; + + material.Widget _leading(ColorScheme cs) { + if (widget.selected) { + return material.Icon( + material.Icons.check_rounded, + size: QueryaDropdownTokens.selectedCheckSize, + color: cs.primary, + ); + } + if (widget.item.leading != null) { + return widget.item.leading!; + } + return const material.SizedBox(width: QueryaDropdownTokens.selectedCheckSlotWidth); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = widget.colorScheme; + final bg = _hovered + ? cs.accent.withValues(alpha: 0.12) + : widget.selected + ? cs.muted.withValues(alpha: 0.28) + : material.Colors.transparent; + + return material.MouseRegion( + cursor: widget.enabled + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + onEnter: widget.enabled ? (_) => setState(() => _hovered = true) : null, + onExit: widget.enabled ? (_) => setState(() => _hovered = false) : null, + child: material.MenuItemButton( + style: material.MenuItemButton.styleFrom( + minimumSize: const material.Size( + QueryaDropdownTokens.menuItemMinWidth, + QueryaDropdownTokens.menuItemHeight, + ), + padding: material.EdgeInsets.zero, + foregroundColor: cs.popoverForeground, + disabledForegroundColor: cs.mutedForeground.withValues(alpha: 0.5), + overlayColor: material.Colors.transparent, + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular( + QueryaDropdownTokens.menuBorderRadius, + ), + ), + ), + onPressed: widget.enabled ? widget.onPick : null, + child: material.AnimatedContainer( + duration: const Duration( + milliseconds: QueryaDropdownTokens.hoverAnimationMs, + ), + curve: material.Curves.easeOut, + padding: QueryaDropdownTokens.menuItemPadding, + decoration: material.BoxDecoration( + color: bg, + borderRadius: material.BorderRadius.circular( + QueryaDropdownTokens.menuBorderRadius, + ), + ), + child: material.Row( + children: [ + material.SizedBox( + width: QueryaDropdownTokens.selectedCheckSlotWidth, + child: _leading(cs), + ), + const material.SizedBox(width: 6), + material.Expanded( + child: material.Text( + widget.item.label, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: QueryaDropdownTokens.fontSize, + fontWeight: widget.selected + ? material.FontWeight.w600 + : material.FontWeight.w400, + color: cs.popoverForeground, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/shared/widgets/querya_dropdown_tokens.dart b/lib/shared/widgets/querya_dropdown_tokens.dart new file mode 100644 index 00000000..9fe851a1 --- /dev/null +++ b/lib/shared/widgets/querya_dropdown_tokens.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart' as material; + +/// Fixed metrics for [QueryaDropdown] — single source of truth for dropdown UI. +abstract final class QueryaDropdownTokens { + /// Compact desktop trigger height. + static const double triggerHeight = 32.0; + + static const material.EdgeInsets triggerPadding = + material.EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0); + + static const double triggerChevronGap = 8.0; + + static const double triggerChevronSize = 18.0; + + static const material.Offset menuAlignmentOffset = material.Offset(0, 4.0); + + static const double menuMaxHeight = 300.0; + + static const int menuScrollItemThreshold = 8; + + static const double menuBorderRadius = 6.0; + + static const double menuElevation = 8.0; + + static const double menuShadowBlurRadius = 8.0; + + static const material.Color menuShadowColor = material.Color(0x42000000); + + static const material.EdgeInsets menuPadding = + material.EdgeInsets.symmetric(vertical: 4.0, horizontal: 4.0); + + static const double menuItemHeight = 28.0; + + static const material.EdgeInsets menuItemPadding = + material.EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0); + + static const double menuItemMinWidth = 180.0; + + static const double fontSize = 13.0; + + static const double selectedCheckSize = 16.0; + + static const double selectedCheckSlotWidth = 18.0; + + static const int hoverAnimationMs = 120; +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 12750aeb..7dfe6fff 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -9,5 +9,5 @@ library; export 'app_dialog.dart'; -export 'querya_dropdown.dart'; +export 'querya_dropdown.dart' show QueryaDropdown, QueryaDropdownItem, QueryaDropdownTokens; export 'package:shadcn_flutter/shadcn_flutter.dart'; diff --git a/test/shared/querya_dropdown_test.dart b/test/shared/querya_dropdown_test.dart index 0f1a9c58..fb1e0211 100644 --- a/test/shared/querya_dropdown_test.dart +++ b/test/shared/querya_dropdown_test.dart @@ -5,8 +5,18 @@ import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; import '../support/querya_theme_test_shell.dart'; void main() { + group('QueryaDropdownTokens', () { + test('uses design-system defaults from issue #89', () { + expect(QueryaDropdownTokens.triggerHeight, 32.0); + expect(QueryaDropdownTokens.menuAlignmentOffset, const material.Offset(0, 4)); + expect(QueryaDropdownTokens.menuMaxHeight, 300.0); + expect(QueryaDropdownTokens.menuBorderRadius, 6.0); + expect(QueryaDropdownTokens.fontSize, 13.0); + }); + }); + group('QueryaDropdown', () { - testWidgets('builds MenuAnchor with current label', (tester) async { + testWidgets('builds MenuAnchor with current label and chevron', (tester) async { await tester.pumpWidget( queryaThemeTestShell( child: material.Scaffold( @@ -25,6 +35,10 @@ void main() { expect(find.byType(material.MenuAnchor), findsOneWidget); expect(find.text('Beta'), findsOneWidget); + expect(find.byIcon(material.Icons.keyboard_arrow_down_rounded), findsOneWidget); + + final box = tester.getSize(find.byType(material.AnimatedContainer).first); + expect(box.height, QueryaDropdownTokens.triggerHeight); }); testWidgets('opens menu and reports selection', (tester) async { @@ -54,6 +68,29 @@ void main() { expect(picked, 2); }); + testWidgets('shows check on selected menu item', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaDropdown( + value: 'b', + items: const [ + QueryaDropdownItem(value: 'a', label: 'Alpha'), + QueryaDropdownItem(value: 'b', label: 'Beta'), + ], + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Beta')); + await tester.pumpAndSettle(); + + expect(find.byIcon(material.Icons.check_rounded), findsOneWidget); + }); + testWidgets('shows hint when value is null', (tester) async { await tester.pumpWidget( queryaThemeTestShell( From bb74f11cd893de8d484fd600ec5c4f3329b79246 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 9 Jun 2026 13:17:10 +0300 Subject: [PATCH 2/5] fix(ui): readable uniform preferences dropdowns and interface scale (#89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix trigger text clipping, align all Preferences dropdowns via PreferencesFieldRow, and add global UI scale (85–150%) with scaled QueryaDropdown metrics. --- .flutter-plugins-dependencies | 2 +- lib/app/app.dart | 26 ++- lib/core/layout/ui_scale.dart | 29 +++ lib/core/layout/ui_scale_controller.dart | 22 ++ lib/core/storage/app_settings.dart | 34 +++ .../preferences_appearance_section.dart | 160 ++++++++------ .../settings/preferences_controls.dart | 55 ++++- lib/features/settings/preferences_dialog.dart | 203 +++++++----------- .../sql_statement_timeout_dropdown.dart | 3 + lib/main.dart | 2 + lib/shared/widgets/querya_dropdown.dart | 124 ++++++----- .../widgets/querya_dropdown_tokens.dart | 65 +++++- lib/shared/widgets/widgets.dart | 3 +- test/core/storage/app_settings_test.dart | 14 ++ test/shared/querya_dropdown_test.dart | 4 +- test/support/querya_theme_test_shell.dart | 10 +- 16 files changed, 487 insertions(+), 269 deletions(-) create mode 100644 lib/core/layout/ui_scale.dart create mode 100644 lib/core/layout/ui_scale_controller.dart diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index e58e7712..d16280a5 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_ios","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_ios-0.5.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"android":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_android-0.5.2+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"sqflite_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_macos-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_macos-0.9.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_macos-3.1.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_linux-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_windows-0.1.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_windows-0.9.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-3.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"web":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","dependencies":[],"dev_dependency":false},{"name":"file_selector_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_web-0.9.4+2/","dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-1.2.1/","dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","dependencies":["device_info_plus"],"dev_dependency":false}]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"device_info_plus","dependencies":[]},{"name":"file_selector","dependencies":["file_selector_android","file_selector_ios","file_selector_linux","file_selector_macos","file_selector_web","file_selector_windows"]},{"name":"file_selector_android","dependencies":[]},{"name":"file_selector_ios","dependencies":[]},{"name":"file_selector_linux","dependencies":[]},{"name":"file_selector_macos","dependencies":[]},{"name":"file_selector_web","dependencies":[]},{"name":"file_selector_windows","dependencies":[]},{"name":"flutter_secure_storage","dependencies":["flutter_secure_storage_linux","flutter_secure_storage_macos","flutter_secure_storage_web","flutter_secure_storage_windows"]},{"name":"flutter_secure_storage_linux","dependencies":[]},{"name":"flutter_secure_storage_macos","dependencies":[]},{"name":"flutter_secure_storage_web","dependencies":[]},{"name":"flutter_secure_storage_windows","dependencies":["path_provider"]},{"name":"irondash_engine_context","dependencies":[]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"super_native_extensions","dependencies":["irondash_engine_context","device_info_plus"]}],"date_created":"2026-05-28 10:58:45.738032","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_ios","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_ios-0.5.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"android":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_android-0.5.2+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"sqflite_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_macos-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_macos-0.9.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_macos-3.1.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_linux-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_windows-0.1.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_windows-0.9.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-3.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"web":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","dependencies":[],"dev_dependency":false},{"name":"file_selector_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_web-0.9.4+2/","dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-1.2.1/","dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","dependencies":["device_info_plus"],"dev_dependency":false}]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"device_info_plus","dependencies":[]},{"name":"file_selector","dependencies":["file_selector_android","file_selector_ios","file_selector_linux","file_selector_macos","file_selector_web","file_selector_windows"]},{"name":"file_selector_android","dependencies":[]},{"name":"file_selector_ios","dependencies":[]},{"name":"file_selector_linux","dependencies":[]},{"name":"file_selector_macos","dependencies":[]},{"name":"file_selector_web","dependencies":[]},{"name":"file_selector_windows","dependencies":[]},{"name":"flutter_secure_storage","dependencies":["flutter_secure_storage_linux","flutter_secure_storage_macos","flutter_secure_storage_web","flutter_secure_storage_windows"]},{"name":"flutter_secure_storage_linux","dependencies":[]},{"name":"flutter_secure_storage_macos","dependencies":[]},{"name":"flutter_secure_storage_web","dependencies":[]},{"name":"flutter_secure_storage_windows","dependencies":["path_provider"]},{"name":"irondash_engine_context","dependencies":[]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"super_native_extensions","dependencies":["irondash_engine_context","device_info_plus"]}],"date_created":"2026-06-09 13:00:56.820075","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/lib/app/app.dart b/lib/app/app.dart index 06b6fdfa..b14e41c4 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,3 +1,5 @@ +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; import 'package:querya_desktop/core/theme/querya_material_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; @@ -13,11 +15,14 @@ class QueryaApp extends StatelessWidget { Widget build(BuildContext context) { final themeController = ThemeController.instance; + final uiScaleController = UiScaleController.instance; + return ListenableBuilder( - listenable: themeController, + listenable: Listenable.merge([themeController, uiScaleController]), builder: (context, _) { final queryaTheme = themeController.activeTheme; final colorScheme = queryaTheme.colorScheme; + final scale = uiScaleController.scale; return ShadcnApp( title: 'Querya', theme: themeController.lightShadcnTheme, @@ -28,10 +33,21 @@ class QueryaApp extends StatelessWidget { enableThemeAnimation: themeController.themeAnimationEnabled, enableScrollInterception: false, // Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens. - builder: (context, child) => QueryaThemeScope( - data: queryaTheme, - child: child ?? const SizedBox.shrink(), - ), + builder: (context, child) { + final mq = MediaQuery.maybeOf(context); + return QueryaUiScaleScope( + scale: scale, + child: MediaQuery( + data: (mq ?? const MediaQueryData()).copyWith( + textScaler: TextScaler.linear(scale), + ), + child: QueryaThemeScope( + data: queryaTheme, + child: child ?? const SizedBox.shrink(), + ), + ), + ); + }, home: const AppLifecycleCleanup( child: MainScreen(), ), diff --git a/lib/core/layout/ui_scale.dart b/lib/core/layout/ui_scale.dart new file mode 100644 index 00000000..b290b8b2 --- /dev/null +++ b/lib/core/layout/ui_scale.dart @@ -0,0 +1,29 @@ +import 'package:flutter/widgets.dart'; + +/// App-wide UI scale factor (Preferences → Appearance). +class QueryaUiScaleScope extends InheritedWidget { + const QueryaUiScaleScope({ + super.key, + required this.scale, + required super.child, + }); + + final double scale; + + static double of(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.scale ?? + 1.0; + } + + @override + bool updateShouldNotify(QueryaUiScaleScope oldWidget) => + oldWidget.scale != scale; +} + +extension QueryaUiScaleContext on BuildContext { + double get uiScale => QueryaUiScaleScope.of(this); + + double scaled(double logicalPixels) => logicalPixels * uiScale; +} diff --git a/lib/core/layout/ui_scale_controller.dart b/lib/core/layout/ui_scale_controller.dart new file mode 100644 index 00000000..f0cc2234 --- /dev/null +++ b/lib/core/layout/ui_scale_controller.dart @@ -0,0 +1,22 @@ +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; + +/// Loads and broadcasts [AppSettings] UI scale for the widget tree. +class UiScaleController extends ChangeNotifier { + UiScaleController._(); + static final UiScaleController instance = UiScaleController._(); + + double _scale = kDefaultUiScale; + double get scale => _scale; + + Future load() async { + _scale = await AppSettings.instance.getUiScale(); + notifyListeners(); + } + + Future setScale(double value) async { + await AppSettings.instance.setUiScale(value); + _scale = await AppSettings.instance.getUiScale(); + notifyListeners(); + } +} diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 77c537bf..e5965dd3 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -22,6 +22,19 @@ const List kSqlResultMaxRowsPresets = [ /// Default monospace size in the SQL editor (logical pixels). const double kDefaultSqlEditorFontSize = 13; +/// Default interface scale (1.0 = 100%). +const double kDefaultUiScale = 1.0; + +/// Allowed UI scale presets (nearest is used when persisting). +const List kUiScalePresets = [0.85, 0.9, 1.0, 1.1, 1.25, 1.5]; + +double _normalizeUiScale(double value) { + final clamped = value.clamp(0.85, 1.5); + return kUiScalePresets.reduce( + (a, b) => (clamped - a).abs() <= (clamped - b).abs() ? a : b, + ); +} + /// Default cap on stored SQL history entries per connection + database. const int kDefaultSqlHistoryMaxEntries = 100; @@ -57,6 +70,7 @@ abstract final class AppSettingsKeys { static const themeImportName = 'theme_import_name'; static const themeImportedColorsJson = 'theme_imported_colors_json'; static const themeAnimationEnabled = 'theme_animation_enabled'; + static const uiScale = 'ui_scale'; } /// Bumps [listenable] when any preference is persisted so open screens can reload. @@ -159,6 +173,26 @@ class AppSettings { AppSettingsRevision.bump(); } + /// Global interface scale for typography and compact controls. + Future getUiScale() async { + final v = await LocalDb.instance.getAppSetting(AppSettingsKeys.uiScale); + if (v == null || v.isEmpty) return kDefaultUiScale; + final n = double.tryParse(v); + if (n == null) return kDefaultUiScale; + return _normalizeUiScale(n); + } + + Future setUiScale(double scale) async { + final preset = kUiScalePresets.contains(scale) + ? scale + : _normalizeUiScale(scale); + await LocalDb.instance.setAppSetting( + AppSettingsKeys.uiScale, + preset.toString(), + ); + AppSettingsRevision.bump(); + } + /// Max SQL history rows kept per connection + database (oldest trimmed). Future getSqlHistoryMaxEntries() async { final v = await LocalDb.instance.getAppSetting( diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 84fe0961..6b4b3e85 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -2,12 +2,18 @@ import 'dart:async' show unawaited; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/theme/querya_theme_preset.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/theme/theme_import_service.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; + +String _uiScaleLabel(double scale) { + final pct = (scale * 100).round(); + return '$pct%'; +} /// Appearance / theme controls for [PreferencesDialog]. class PreferencesAppearanceSection extends material.StatefulWidget { @@ -21,6 +27,7 @@ class PreferencesAppearanceSection extends material.StatefulWidget { class _PreferencesAppearanceSectionState extends material.State { final _controller = ThemeController.instance; + final _uiScale = UiScaleController.instance; String? _importError; bool _importing = false; @@ -28,11 +35,13 @@ class _PreferencesAppearanceSectionState void initState() { super.initState(); _controller.addListener(_onThemeChanged); + _uiScale.addListener(_onThemeChanged); } @override void dispose() { _controller.removeListener(_onThemeChanged); + _uiScale.removeListener(_onThemeChanged); super.dispose(); } @@ -48,6 +57,10 @@ class _PreferencesAppearanceSectionState await _controller.setPreset(preset); } + Future _setUiScale(double scale) async { + await _uiScale.setScale(scale); + } + Future _pickAndImportTheme() async { setState(() { _importing = true; @@ -101,77 +114,90 @@ class _PreferencesAppearanceSectionState children: [ const Text('Appearance').semiBold().small().foreground(), const material.SizedBox(height: 8), - material.Row( - children: [ - const Text('Theme mode').small().foreground(), - const material.SizedBox(width: 12), - PreferencesDropdownMenu( - value: c.themeMode, - onSelected: (v) { - if (v != null) unawaited(_setThemeMode(v)); - }, - entries: const [ - material.DropdownMenuEntry( - value: ThemeMode.dark, - label: 'Dark', - ), - material.DropdownMenuEntry( - value: ThemeMode.light, - label: 'Light', - ), - material.DropdownMenuEntry( - value: ThemeMode.system, - label: 'System', - ), - ], - ), - ], + PreferencesFieldRow( + label: 'Theme mode', + control: PreferencesDropdownMenu( + value: c.themeMode, + onSelected: (v) { + if (v != null) unawaited(_setThemeMode(v)); + }, + entries: const [ + material.DropdownMenuEntry( + value: ThemeMode.dark, + label: 'Dark', + ), + material.DropdownMenuEntry( + value: ThemeMode.light, + label: 'Light', + ), + material.DropdownMenuEntry( + value: ThemeMode.system, + label: 'System', + ), + ], + ), ), const material.SizedBox(height: 12), - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Padding( - padding: const material.EdgeInsets.only(top: 10), - child: const Text('Color preset').small().foreground(), - ), - const material.SizedBox(width: 12), - material.Expanded( - child: PreferencesDropdownMenu( - value: c.preset, - expandToParent: true, - onSelected: (v) { - if (v != null) unawaited(_setPreset(v)); - }, - entries: [ - const material.DropdownMenuEntry( - value: QueryaThemePreset.queryaDark, - label: 'Querya Dark', - ), - const material.DropdownMenuEntry( - value: QueryaThemePreset.queryaLight, - label: 'Querya Light', - ), - material.DropdownMenuEntry( - value: QueryaThemePreset.imported, - enabled: c.hasImportedTheme, - label: importedLabel, - ), - ], + PreferencesFieldRow( + label: 'Color preset', + control: PreferencesDropdownMenu( + value: c.preset, + onSelected: (v) { + if (v != null) unawaited(_setPreset(v)); + }, + entries: [ + const material.DropdownMenuEntry( + value: QueryaThemePreset.queryaDark, + label: 'Querya Dark', ), - ), - ], + const material.DropdownMenuEntry( + value: QueryaThemePreset.queryaLight, + label: 'Querya Light', + ), + material.DropdownMenuEntry( + value: QueryaThemePreset.imported, + enabled: c.hasImportedTheme, + label: importedLabel, + ), + ], + ), ), const material.SizedBox(height: 12), - material.Row( - children: [ - const Text('Animate theme changes').small().foreground(), - const material.SizedBox(width: 12), - material.Switch( - value: c.themeAnimationEnabled, - onChanged: (v) => unawaited(_setThemeAnimation(v)), - ), - ], + PreferencesFieldRow( + label: 'Interface scale', + hint: 'Scales labels, menus, and compact controls across the app.', + control: PreferencesDropdownMenu( + value: _uiScale.scale, + onSelected: (v) { + if (v != null) unawaited(_setUiScale(v)); + }, + entries: [ + for (final scale in kUiScalePresets) + material.DropdownMenuEntry( + value: scale, + label: _uiScaleLabel(scale), + ), + ], + ), + ), + const material.SizedBox(height: 12), + PreferencesFieldRow( + label: 'Animate theme changes', + control: material.Builder( + builder: (context) { + final h = QueryaDropdownTokens.scaledTriggerHeight(context); + return material.SizedBox( + height: h, + child: material.Align( + alignment: material.Alignment.centerLeft, + child: material.Switch( + value: c.themeAnimationEnabled, + onChanged: (v) => unawaited(_setThemeAnimation(v)), + ), + ), + ); + }, + ), ), const material.SizedBox(height: 4), const PreferencesHint( diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index f5b40850..0b462ed7 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -22,6 +22,55 @@ class PreferencesHint extends StatelessWidget { } } +/// Label + full-width control row for Preferences (uniform dropdown width). +class PreferencesFieldRow extends StatelessWidget { + const PreferencesFieldRow({ + super.key, + required this.label, + required this.control, + this.hint, + this.labelWidth = kPreferencesLabelWidth, + }); + + final String label; + final material.Widget control; + final String? hint; + final double labelWidth; + + @override + material.Widget build(material.BuildContext context) { + final triggerHeight = QueryaDropdownTokens.scaledTriggerHeight(context); + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.SizedBox( + width: labelWidth, + height: triggerHeight, + child: material.Align( + alignment: material.Alignment.centerLeft, + child: Text(label).small().foreground(), + ), + ), + const material.SizedBox(width: 12), + material.Expanded(child: control), + ], + ), + if (hint != null) ...[ + const material.SizedBox(height: 4), + material.Padding( + padding: material.EdgeInsets.only(left: labelWidth + 12), + child: PreferencesHint(hint!), + ), + ], + ], + ); + } +} + /// Preferences dropdown backed by [QueryaDropdown] ([MenuAnchor]). class PreferencesDropdownMenu extends StatelessWidget { const PreferencesDropdownMenu({ @@ -30,7 +79,7 @@ class PreferencesDropdownMenu extends StatelessWidget { required this.entries, required this.onSelected, this.width, - this.expandToParent = false, + this.expandToParent = true, this.enabled = true, }); @@ -38,10 +87,10 @@ class PreferencesDropdownMenu extends StatelessWidget { final List> entries; final material.ValueChanged onSelected; - /// Fixed width for field + menu. Do not pass [double.infinity] — menu glitches. + /// Fixed width when [expandToParent] is false. final double? width; - /// Fill [Expanded] parent width without stretching the popup to screen width. + /// Fill parent — use inside [PreferencesFieldRow]. final bool expandToParent; final bool enabled; diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 1a84d5f1..664b48bb 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -144,17 +144,13 @@ class _PreferencesDialogContentState .small() .foreground(), const material.SizedBox(height: 8), - material.Row( - children: [ - const Text('Statement timeout') - .small() - .foreground(), - const material.SizedBox(width: 12), - SqlStatementTimeoutDropdown( - value: _pgTimeout, - onChanged: (v) => unawaited(_setPg(v)), - ), - ], + PreferencesFieldRow( + label: 'Statement timeout', + control: SqlStatementTimeoutDropdown( + value: _pgTimeout, + expandToParent: true, + onChanged: (v) => unawaited(_setPg(v)), + ), ), const material.SizedBox(height: 24), const Text('SQL — MySQL / MariaDB') @@ -162,17 +158,13 @@ class _PreferencesDialogContentState .small() .foreground(), const material.SizedBox(height: 8), - material.Row( - children: [ - const Text('Statement timeout') - .small() - .foreground(), - const material.SizedBox(width: 12), - SqlStatementTimeoutDropdown( - value: _mysqlTimeout, - onChanged: (v) => unawaited(_setMysql(v)), - ), - ], + PreferencesFieldRow( + label: 'Statement timeout', + control: SqlStatementTimeoutDropdown( + value: _mysqlTimeout, + expandToParent: true, + onChanged: (v) => unawaited(_setMysql(v)), + ), ), const material.SizedBox(height: 24), const Text('SQL editor') @@ -180,109 +172,78 @@ class _PreferencesDialogContentState .small() .foreground(), const material.SizedBox(height: 8), - material.Row( - children: [ - const Text('Max rows in results') - .small() - .foreground(), - const material.SizedBox(width: 12), - PreferencesDropdownMenu( - value: _maxRows, - onSelected: (v) { - if (v != null) unawaited(_setMaxRows(v)); - }, - entries: [ - for (final n in kSqlResultMaxRowsPresets) - material.DropdownMenuEntry( - value: n, - label: '$n', - ), - ], - ), - ], - ), - const material.SizedBox(height: 12), - material.Row( - crossAxisAlignment: - material.CrossAxisAlignment.start, - children: [ - material.Padding( - padding: - const material.EdgeInsets.only(top: 8), - child: const Text('Query history limit') - .small() - .foreground(), - ), - const material.SizedBox(width: 12), - material.Expanded( - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.start, - children: [ - PreferencesDropdownMenu( - value: _historyMax, - expandToParent: true, - onSelected: (v) { - if (v != null) { - unawaited(_setHistoryMax(v)); - } - }, - entries: [ - for (final n - in kSqlHistoryMaxEntriesPresets) - material.DropdownMenuEntry( - value: n, - label: '$n entries', - ), - ], - ), - const material.SizedBox(height: 4), - const PreferencesHint( - 'Per connection and database; oldest queries are dropped.', - ), - ], - ), - ), - ], - ), - const material.SizedBox(height: 12), - material.Row( - children: [ - const Text('Font size').small().foreground(), - const material.SizedBox(width: 12), - PreferencesDropdownMenu( - value: _fontSize, - onSelected: (v) { - if (v != null) unawaited(_setFont(v)); - }, - entries: const [ - material.DropdownMenuEntry( - value: 11.0, - label: '11 pt', - ), - material.DropdownMenuEntry( - value: 12.0, - label: '12 pt', - ), + PreferencesFieldRow( + label: 'Max rows in results', + control: PreferencesDropdownMenu( + value: _maxRows, + onSelected: (v) { + if (v != null) unawaited(_setMaxRows(v)); + }, + entries: [ + for (final n in kSqlResultMaxRowsPresets) material.DropdownMenuEntry( - value: 13.0, - label: '13 pt', - ), - material.DropdownMenuEntry( - value: 14.0, - label: '14 pt', - ), - material.DropdownMenuEntry( - value: 16.0, - label: '16 pt', + value: n, + label: '$n', ), + ], + ), + ), + const material.SizedBox(height: 12), + PreferencesFieldRow( + label: 'Query history limit', + hint: + 'Per connection and database; oldest queries are dropped.', + control: PreferencesDropdownMenu( + value: _historyMax, + onSelected: (v) { + if (v != null) { + unawaited(_setHistoryMax(v)); + } + }, + entries: [ + for (final n in kSqlHistoryMaxEntriesPresets) material.DropdownMenuEntry( - value: 18.0, - label: '18 pt', + value: n, + label: '$n entries', ), - ], - ), - ], + ], + ), + ), + const material.SizedBox(height: 12), + PreferencesFieldRow( + label: 'Font size', + control: PreferencesDropdownMenu( + value: _fontSize, + onSelected: (v) { + if (v != null) unawaited(_setFont(v)); + }, + entries: const [ + material.DropdownMenuEntry( + value: 11.0, + label: '11 pt', + ), + material.DropdownMenuEntry( + value: 12.0, + label: '12 pt', + ), + material.DropdownMenuEntry( + value: 13.0, + label: '13 pt', + ), + material.DropdownMenuEntry( + value: 14.0, + label: '14 pt', + ), + material.DropdownMenuEntry( + value: 16.0, + label: '16 pt', + ), + material.DropdownMenuEntry( + value: 18.0, + label: '18 pt', + ), + ], + ), ), const material.SizedBox(height: 16), const PreferencesHint( diff --git a/lib/features/settings/sql_statement_timeout_dropdown.dart b/lib/features/settings/sql_statement_timeout_dropdown.dart index 4a664a20..129bb956 100644 --- a/lib/features/settings/sql_statement_timeout_dropdown.dart +++ b/lib/features/settings/sql_statement_timeout_dropdown.dart @@ -30,17 +30,20 @@ class SqlStatementTimeoutDropdown extends material.StatelessWidget { required this.value, required this.onChanged, this.enabled = true, + this.expandToParent = false, }); final int? value; final void Function(int?) onChanged; final bool enabled; + final bool expandToParent; @override material.Widget build(material.BuildContext context) { return QueryaDropdown( value: value, enabled: enabled, + expandToParent: expandToParent, onSelected: onChanged, items: kSqlStatementTimeoutMenuItems, ); diff --git a/lib/main.dart b/lib/main.dart index 14dbd026..887d9a07 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'app/app.dart'; import 'core/editor/syntax_highlight_service.dart'; +import 'core/layout/ui_scale_controller.dart'; import 'core/storage/local_db.dart'; import 'core/theme/theme_controller.dart'; @@ -11,6 +12,7 @@ void main() async { await LocalDb.initFfi(); await SyntaxHighlightService.ensureInitialized(); await ThemeController.instance.load(); + await UiScaleController.instance.load(); runApp(const QueryaApp()); doWhenWindowReady(() { final win = appWindow; diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index 843bf8b0..039f0fa3 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -71,19 +72,18 @@ class _QueryaDropdownState extends material.State> { } material.Widget _triggerLabelText({ + required material.BuildContext context, required String label, required ColorScheme cs, required bool expand, }) { + final textColor = + widget.enabled ? cs.popoverForeground : cs.mutedForeground; final text = material.Text( label, maxLines: 1, overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontSize: QueryaDropdownTokens.fontSize, - fontWeight: material.FontWeight.w500, - color: widget.enabled ? cs.foreground : cs.mutedForeground, - ), + style: QueryaDropdownTokens.triggerTextStyle(context, textColor), ); if (expand) { return material.Expanded(child: text); @@ -121,6 +121,10 @@ class _QueryaDropdownState extends material.State> { final borderColor = widget.enabled ? (_triggerHovered ? cs.ring : cs.border) : cs.border.withValues(alpha: 0.4); + final triggerHeight = QueryaDropdownTokens.scaledTriggerHeight(context); + final chevronGap = context.scaled(QueryaDropdownTokens.triggerChevronGap); + final chevronSize = context.scaled(QueryaDropdownTokens.triggerChevronSize); + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); final triggerBody = material.MouseRegion( cursor: widget.enabled @@ -133,27 +137,31 @@ class _QueryaDropdownState extends material.State> { milliseconds: QueryaDropdownTokens.hoverAnimationMs, ), curve: material.Curves.easeOut, - height: QueryaDropdownTokens.triggerHeight, - padding: QueryaDropdownTokens.triggerPadding, + height: triggerHeight, + padding: QueryaDropdownTokens.scaledTriggerPadding(context), decoration: material.BoxDecoration( color: _triggerHovered - ? cs.muted.withValues(alpha: 0.22) - : cs.muted.withValues(alpha: 0.08), - borderRadius: material.BorderRadius.circular( - QueryaDropdownTokens.menuBorderRadius, - ), + ? cs.muted.withValues(alpha: 0.28) + : cs.muted.withValues(alpha: 0.14), + borderRadius: material.BorderRadius.circular(radius), border: material.Border.all(color: borderColor), ), child: material.Row( mainAxisAlignment: material.MainAxisAlignment.spaceBetween, - mainAxisSize: - widget.expandToParent ? material.MainAxisSize.max : material.MainAxisSize.min, + mainAxisSize: widget.expandToParent + ? material.MainAxisSize.max + : material.MainAxisSize.min, children: [ - _triggerLabelText(label: label, cs: cs, expand: widget.expandToParent), - const material.SizedBox(width: QueryaDropdownTokens.triggerChevronGap), + _triggerLabelText( + context: context, + label: label, + cs: cs, + expand: widget.expandToParent, + ), + material.SizedBox(width: chevronGap), material.Icon( material.Icons.keyboard_arrow_down_rounded, - size: QueryaDropdownTokens.triggerChevronSize, + size: chevronSize, color: widget.enabled ? cs.mutedForeground : cs.mutedForeground.withValues(alpha: 0.5), @@ -175,9 +183,7 @@ class _QueryaDropdownState extends material.State> { } } : null, - borderRadius: material.BorderRadius.circular( - QueryaDropdownTokens.menuBorderRadius, - ), + borderRadius: material.BorderRadius.circular(radius), child: fieldWidth != null ? material.SizedBox(width: fieldWidth, child: triggerBody) : triggerBody, @@ -189,15 +195,23 @@ class _QueryaDropdownState extends material.State> { material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; final label = _labelFor(widget.value); - final fieldWidth = widget.expandToParent ? null : widget.width; + final fieldWidth = widget.expandToParent + ? null + : (widget.width != null ? context.scaled(widget.width!) : null); final menuChildren = widget.items.map((item) => _menuItem(item, cs)).toList(); - final effectiveMaxHeight = widget.items.length > QueryaDropdownTokens.menuScrollItemThreshold - ? widget.menuMaxHeight - : double.infinity; + final scaledMaxHeight = context.scaled(widget.menuMaxHeight); + final effectiveMaxHeight = + widget.items.length > QueryaDropdownTokens.menuScrollItemThreshold + ? scaledMaxHeight + : double.infinity; + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); final anchor = material.MenuAnchor( controller: _controller, - alignmentOffset: widget.alignmentOffset, + alignmentOffset: material.Offset( + widget.alignmentOffset.dx, + context.scaled(widget.alignmentOffset.dy), + ), consumeOutsideTap: true, style: material.MenuStyle( backgroundColor: material.WidgetStatePropertyAll(cs.popover), @@ -216,9 +230,7 @@ class _QueryaDropdownState extends material.State> { ), shape: material.WidgetStatePropertyAll( material.RoundedRectangleBorder( - borderRadius: material.BorderRadius.circular( - QueryaDropdownTokens.menuBorderRadius, - ), + borderRadius: material.BorderRadius.circular(radius), side: material.BorderSide(color: cs.border), ), ), @@ -269,28 +281,34 @@ class _QueryaDropdownMenuItem extends material.StatefulWidget { class _QueryaDropdownMenuItemState extends material.State<_QueryaDropdownMenuItem> { bool _hovered = false; - material.Widget _leading(ColorScheme cs) { + material.Widget _leading(material.BuildContext context, ColorScheme cs) { + final slot = context.scaled(QueryaDropdownTokens.selectedCheckSlotWidth); + final checkSize = context.scaled(QueryaDropdownTokens.selectedCheckSize); if (widget.selected) { return material.Icon( material.Icons.check_rounded, - size: QueryaDropdownTokens.selectedCheckSize, + size: checkSize, color: cs.primary, ); } if (widget.item.leading != null) { return widget.item.leading!; } - return const material.SizedBox(width: QueryaDropdownTokens.selectedCheckSlotWidth); + return material.SizedBox(width: slot); } @override material.Widget build(material.BuildContext context) { final cs = widget.colorScheme; final bg = _hovered - ? cs.accent.withValues(alpha: 0.12) + ? cs.accent.withValues(alpha: 0.14) : widget.selected - ? cs.muted.withValues(alpha: 0.28) + ? cs.muted.withValues(alpha: 0.32) : material.Colors.transparent; + final itemHeight = QueryaDropdownTokens.scaledMenuItemHeight(context); + final minWidth = context.scaled(QueryaDropdownTokens.menuItemMinWidth); + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + final slot = context.scaled(QueryaDropdownTokens.selectedCheckSlotWidth); return material.MouseRegion( cursor: widget.enabled @@ -300,51 +318,43 @@ class _QueryaDropdownMenuItemState extends material.State<_QueryaDropdownMenu onExit: widget.enabled ? (_) => setState(() => _hovered = false) : null, child: material.MenuItemButton( style: material.MenuItemButton.styleFrom( - minimumSize: const material.Size( - QueryaDropdownTokens.menuItemMinWidth, - QueryaDropdownTokens.menuItemHeight, - ), + minimumSize: material.Size(minWidth, itemHeight), padding: material.EdgeInsets.zero, foregroundColor: cs.popoverForeground, disabledForegroundColor: cs.mutedForeground.withValues(alpha: 0.5), overlayColor: material.Colors.transparent, shape: material.RoundedRectangleBorder( - borderRadius: material.BorderRadius.circular( - QueryaDropdownTokens.menuBorderRadius, - ), + borderRadius: material.BorderRadius.circular(radius), ), ), onPressed: widget.enabled ? widget.onPick : null, child: material.AnimatedContainer( duration: const Duration( - milliseconds: QueryaDropdownTokens.hoverAnimationMs, - ), + milliseconds: QueryaDropdownTokens.hoverAnimationMs, + ), curve: material.Curves.easeOut, - padding: QueryaDropdownTokens.menuItemPadding, + constraints: material.BoxConstraints(minHeight: itemHeight), + padding: material.EdgeInsets.symmetric( + horizontal: context.scaled(QueryaDropdownTokens.menuItemPadding.horizontal), + vertical: context.scaled(QueryaDropdownTokens.menuItemPadding.vertical), + ), decoration: material.BoxDecoration( color: bg, - borderRadius: material.BorderRadius.circular( - QueryaDropdownTokens.menuBorderRadius, - ), + borderRadius: material.BorderRadius.circular(radius), ), child: material.Row( children: [ - material.SizedBox( - width: QueryaDropdownTokens.selectedCheckSlotWidth, - child: _leading(cs), - ), - const material.SizedBox(width: 6), + material.SizedBox(width: slot, child: _leading(context, cs)), + material.SizedBox(width: context.scaled(6)), material.Expanded( child: material.Text( widget.item.label, maxLines: 1, overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontSize: QueryaDropdownTokens.fontSize, - fontWeight: widget.selected - ? material.FontWeight.w600 - : material.FontWeight.w400, - color: cs.popoverForeground, + style: QueryaDropdownTokens.menuItemTextStyle( + context, + cs.popoverForeground, + selected: widget.selected, ), ), ), diff --git a/lib/shared/widgets/querya_dropdown_tokens.dart b/lib/shared/widgets/querya_dropdown_tokens.dart index 9fe851a1..7ee67934 100644 --- a/lib/shared/widgets/querya_dropdown_tokens.dart +++ b/lib/shared/widgets/querya_dropdown_tokens.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale.dart'; /// Fixed metrics for [QueryaDropdown] — single source of truth for dropdown UI. abstract final class QueryaDropdownTokens { - /// Compact desktop trigger height. - static const double triggerHeight = 32.0; + /// Compact desktop trigger height (content is vertically centered). + static const double triggerHeight = 36.0; - static const material.EdgeInsets triggerPadding = - material.EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0); + static const double triggerPaddingHorizontal = 12.0; static const double triggerChevronGap = 8.0; @@ -22,25 +22,72 @@ abstract final class QueryaDropdownTokens { static const double menuElevation = 8.0; - static const double menuShadowBlurRadius = 8.0; - static const material.Color menuShadowColor = material.Color(0x42000000); static const material.EdgeInsets menuPadding = material.EdgeInsets.symmetric(vertical: 4.0, horizontal: 4.0); - static const double menuItemHeight = 28.0; + static const double menuItemHeight = 32.0; static const material.EdgeInsets menuItemPadding = - material.EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0); + material.EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0); static const double menuItemMinWidth = 180.0; - static const double fontSize = 13.0; + static const double fontSize = 14.0; + + static const double lineHeight = 1.25; static const double selectedCheckSize = 16.0; static const double selectedCheckSlotWidth = 18.0; static const int hoverAnimationMs = 120; + + static double scaledTriggerHeight(material.BuildContext context) => + context.scaled(triggerHeight); + + static double scaledFontSize(material.BuildContext context) => + context.scaled(fontSize); + + static double scaledMenuItemHeight(material.BuildContext context) => + context.scaled(menuItemHeight); + + static double scaledMenuMaxHeight(material.BuildContext context) => + context.scaled(menuMaxHeight); + + static material.EdgeInsets scaledTriggerPadding(material.BuildContext context) => + material.EdgeInsets.symmetric( + horizontal: context.scaled(triggerPaddingHorizontal), + ); + + static material.TextStyle triggerTextStyle( + material.BuildContext context, + material.Color color, + ) { + final size = scaledFontSize(context); + return material.TextStyle( + fontSize: size, + height: lineHeight, + fontWeight: material.FontWeight.w500, + color: color, + ); + } + + static material.TextStyle menuItemTextStyle( + material.BuildContext context, + material.Color color, { + required bool selected, + }) { + final size = scaledFontSize(context); + return material.TextStyle( + fontSize: size, + height: lineHeight, + fontWeight: selected ? material.FontWeight.w600 : material.FontWeight.w400, + color: color, + ); + } } + +/// Uniform label column width in [PreferencesFieldRow]. +const double kPreferencesLabelWidth = 152.0; diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 7dfe6fff..4c4619cf 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -9,5 +9,6 @@ library; export 'app_dialog.dart'; -export 'querya_dropdown.dart' show QueryaDropdown, QueryaDropdownItem, QueryaDropdownTokens; +export 'querya_dropdown.dart' + show QueryaDropdown, QueryaDropdownItem, QueryaDropdownTokens, kPreferencesLabelWidth; export 'package:shadcn_flutter/shadcn_flutter.dart'; diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 5b1a0d99..16fa0b8a 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -229,6 +229,20 @@ void main() { }); }); + group('ui scale', () { + test('defaults to 1.0 and snaps to presets', () async { + expect(await AppSettings.instance.getUiScale(), kDefaultUiScale); + + await AppSettings.instance.setUiScale(1.12); + expect(await AppSettings.instance.getUiScale(), 1.1); + + await AppSettings.instance.setUiScale(1.5); + expect(await AppSettings.instance.getUiScale(), 1.5); + + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.uiScale); + }); + }); + group('AppSettingsRevision', () { test('bump increments listenable value', () { final start = AppSettingsRevision.listenable.value; diff --git a/test/shared/querya_dropdown_test.dart b/test/shared/querya_dropdown_test.dart index fb1e0211..408ae03a 100644 --- a/test/shared/querya_dropdown_test.dart +++ b/test/shared/querya_dropdown_test.dart @@ -7,11 +7,11 @@ import '../support/querya_theme_test_shell.dart'; void main() { group('QueryaDropdownTokens', () { test('uses design-system defaults from issue #89', () { - expect(QueryaDropdownTokens.triggerHeight, 32.0); + expect(QueryaDropdownTokens.triggerHeight, 36.0); expect(QueryaDropdownTokens.menuAlignmentOffset, const material.Offset(0, 4)); expect(QueryaDropdownTokens.menuMaxHeight, 300.0); expect(QueryaDropdownTokens.menuBorderRadius, 6.0); - expect(QueryaDropdownTokens.fontSize, 13.0); + expect(QueryaDropdownTokens.fontSize, 14.0); }); }); diff --git a/test/support/querya_theme_test_shell.dart b/test/support/querya_theme_test_shell.dart index 32f13e6b..11090d9b 100644 --- a/test/support/querya_theme_test_shell.dart +++ b/test/support/querya_theme_test_shell.dart @@ -1,3 +1,4 @@ +import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -13,9 +14,12 @@ Widget queryaThemeTestShell({ theme: td, darkTheme: td, themeMode: ThemeMode.dark, - builder: (context, appChild) => QueryaThemeScope( - data: data, - child: appChild ?? const SizedBox.shrink(), + builder: (context, appChild) => QueryaUiScaleScope( + scale: 1.0, + child: QueryaThemeScope( + data: data, + child: appChild ?? const SizedBox.shrink(), + ), ), home: child, ); From 4495bc4a198535ad32e24a2e7cd5e98b0e0c4e63 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 9 Jun 2026 18:54:59 +0300 Subject: [PATCH 3/5] feat(ui): match dropdown menu width to trigger and add scale slider (#89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menu panel follows anchor width via crossAxisUnconstrained; replace interface scale presets with a Telegram-style 75–200% slider with live preview. --- lib/core/layout/ui_scale_controller.dart | 19 ++++++- lib/core/storage/app_settings.dart | 23 ++++---- .../preferences_appearance_section.dart | 27 ++-------- .../settings/preferences_controls.dart | 53 +++++++++++++++++++ lib/shared/widgets/querya_dropdown.dart | 4 +- .../widgets/querya_dropdown_tokens.dart | 2 - test/core/storage/app_settings_test.dart | 11 ++-- .../settings/interface_scale_slider_test.dart | 42 +++++++++++++++ test/shared/querya_dropdown_test.dart | 27 ++++++++++ 9 files changed, 165 insertions(+), 43 deletions(-) create mode 100644 test/features/settings/interface_scale_slider_test.dart diff --git a/lib/core/layout/ui_scale_controller.dart b/lib/core/layout/ui_scale_controller.dart index f0cc2234..1ae2c6d8 100644 --- a/lib/core/layout/ui_scale_controller.dart +++ b/lib/core/layout/ui_scale_controller.dart @@ -14,9 +14,26 @@ class UiScaleController extends ChangeNotifier { notifyListeners(); } - Future setScale(double value) async { + /// Live preview while dragging the scale slider (not persisted). + void setScalePreview(double value) { + final next = _normalizeUiScale(value); + if (next == _scale) return; + _scale = next; + notifyListeners(); + } + + /// Persist scale to SQLite (called on slider release). + Future commitScale(double value) async { await AppSettings.instance.setUiScale(value); _scale = await AppSettings.instance.getUiScale(); notifyListeners(); } + + Future setScale(double value) => commitScale(value); + + double _normalizeUiScale(double value) { + final clamped = value.clamp(kMinUiScale, kMaxUiScale); + final steps = ((clamped - kMinUiScale) / kUiScaleStep).round(); + return (kMinUiScale + steps * kUiScaleStep).clamp(kMinUiScale, kMaxUiScale); + } } diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index e5965dd3..04fa7390 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -25,14 +25,19 @@ const double kDefaultSqlEditorFontSize = 13; /// Default interface scale (1.0 = 100%). const double kDefaultUiScale = 1.0; -/// Allowed UI scale presets (nearest is used when persisting). -const List kUiScalePresets = [0.85, 0.9, 1.0, 1.1, 1.25, 1.5]; +/// Minimum interface scale (Telegram Desktop supports 75%). +const double kMinUiScale = 0.75; + +/// Maximum interface scale (Telegram goes to 300%; 200% is enough for Querya). +const double kMaxUiScale = 2.0; + +/// Slider step — 1% increments, like Telegram's interface scale control. +const double kUiScaleStep = 0.01; double _normalizeUiScale(double value) { - final clamped = value.clamp(0.85, 1.5); - return kUiScalePresets.reduce( - (a, b) => (clamped - a).abs() <= (clamped - b).abs() ? a : b, - ); + final clamped = value.clamp(kMinUiScale, kMaxUiScale); + final steps = ((clamped - kMinUiScale) / kUiScaleStep).round(); + return (kMinUiScale + steps * kUiScaleStep).clamp(kMinUiScale, kMaxUiScale); } /// Default cap on stored SQL history entries per connection + database. @@ -183,12 +188,10 @@ class AppSettings { } Future setUiScale(double scale) async { - final preset = kUiScalePresets.contains(scale) - ? scale - : _normalizeUiScale(scale); + final normalized = _normalizeUiScale(scale); await LocalDb.instance.setAppSetting( AppSettingsKeys.uiScale, - preset.toString(), + normalized.toStringAsFixed(2), ); AppSettingsRevision.bump(); } diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 6b4b3e85..3c1c3645 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -3,18 +3,12 @@ import 'dart:async' show unawaited; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; -import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/theme/querya_theme_preset.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/theme/theme_import_service.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -String _uiScaleLabel(double scale) { - final pct = (scale * 100).round(); - return '$pct%'; -} - /// Appearance / theme controls for [PreferencesDialog]. class PreferencesAppearanceSection extends material.StatefulWidget { const PreferencesAppearanceSection({super.key}); @@ -57,10 +51,6 @@ class _PreferencesAppearanceSectionState await _controller.setPreset(preset); } - Future _setUiScale(double scale) async { - await _uiScale.setScale(scale); - } - Future _pickAndImportTheme() async { setState(() { _importing = true; @@ -165,20 +155,9 @@ class _PreferencesAppearanceSectionState const material.SizedBox(height: 12), PreferencesFieldRow( label: 'Interface scale', - hint: 'Scales labels, menus, and compact controls across the app.', - control: PreferencesDropdownMenu( - value: _uiScale.scale, - onSelected: (v) { - if (v != null) unawaited(_setUiScale(v)); - }, - entries: [ - for (final scale in kUiScalePresets) - material.DropdownMenuEntry( - value: scale, - label: _uiScaleLabel(scale), - ), - ], - ), + hint: + 'Drag to resize the UI (75–200%). Changes apply live; release to save.', + control: InterfaceScaleSlider(scale: _uiScale.scale), ), const material.SizedBox(height: 12), PreferencesFieldRow( diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index 0b462ed7..e976d36d 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -1,4 +1,8 @@ +import 'dart:async' show unawaited; + import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -71,6 +75,55 @@ class PreferencesFieldRow extends StatelessWidget { } } +/// Telegram-style interface scale slider (75–200%, 1% steps, live preview). +class InterfaceScaleSlider extends StatelessWidget { + const InterfaceScaleSlider({super.key, required this.scale}); + + final double scale; + + static int get _divisions => + ((kMaxUiScale - kMinUiScale) / kUiScaleStep).round(); + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final pct = (scale * 100).round(); + + return material.Row( + children: [ + material.Expanded( + child: Slider( + value: SliderValue.single(scale), + min: kMinUiScale, + max: kMaxUiScale, + divisions: _divisions, + hintValue: const SliderValue.single(kDefaultUiScale), + onChanged: (value) { + UiScaleController.instance.setScalePreview(value.value); + }, + onChangeEnd: (value) { + unawaited(UiScaleController.instance.commitScale(value.value)); + }, + ), + ), + const material.SizedBox(width: 8), + material.SizedBox( + width: 44, + child: material.Text( + '$pct%', + textAlign: material.TextAlign.right, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.popoverForeground, + ), + ), + ), + ], + ); + } +} + /// Preferences dropdown backed by [QueryaDropdown] ([MenuAnchor]). class PreferencesDropdownMenu extends StatelessWidget { const PreferencesDropdownMenu({ diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index 039f0fa3..3480ec79 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -208,6 +208,7 @@ class _QueryaDropdownState extends material.State> { final anchor = material.MenuAnchor( controller: _controller, + crossAxisUnconstrained: false, alignmentOffset: material.Offset( widget.alignmentOffset.dx, context.scaled(widget.alignmentOffset.dy), @@ -306,7 +307,6 @@ class _QueryaDropdownMenuItemState extends material.State<_QueryaDropdownMenu ? cs.muted.withValues(alpha: 0.32) : material.Colors.transparent; final itemHeight = QueryaDropdownTokens.scaledMenuItemHeight(context); - final minWidth = context.scaled(QueryaDropdownTokens.menuItemMinWidth); final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); final slot = context.scaled(QueryaDropdownTokens.selectedCheckSlotWidth); @@ -318,7 +318,7 @@ class _QueryaDropdownMenuItemState extends material.State<_QueryaDropdownMenu onExit: widget.enabled ? (_) => setState(() => _hovered = false) : null, child: material.MenuItemButton( style: material.MenuItemButton.styleFrom( - minimumSize: material.Size(minWidth, itemHeight), + minimumSize: material.Size(double.infinity, itemHeight), padding: material.EdgeInsets.zero, foregroundColor: cs.popoverForeground, disabledForegroundColor: cs.mutedForeground.withValues(alpha: 0.5), diff --git a/lib/shared/widgets/querya_dropdown_tokens.dart b/lib/shared/widgets/querya_dropdown_tokens.dart index 7ee67934..367bbaaf 100644 --- a/lib/shared/widgets/querya_dropdown_tokens.dart +++ b/lib/shared/widgets/querya_dropdown_tokens.dart @@ -32,8 +32,6 @@ abstract final class QueryaDropdownTokens { static const material.EdgeInsets menuItemPadding = material.EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0); - static const double menuItemMinWidth = 180.0; - static const double fontSize = 14.0; static const double lineHeight = 1.25; diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 16fa0b8a..dfeba43d 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -230,14 +230,17 @@ void main() { }); group('ui scale', () { - test('defaults to 1.0 and snaps to presets', () async { + test('defaults to 1.0 and stores continuous 1% steps', () async { expect(await AppSettings.instance.getUiScale(), kDefaultUiScale); await AppSettings.instance.setUiScale(1.12); - expect(await AppSettings.instance.getUiScale(), 1.1); + expect(await AppSettings.instance.getUiScale(), closeTo(1.12, 0.001)); - await AppSettings.instance.setUiScale(1.5); - expect(await AppSettings.instance.getUiScale(), 1.5); + await AppSettings.instance.setUiScale(0.75); + expect(await AppSettings.instance.getUiScale(), kMinUiScale); + + await AppSettings.instance.setUiScale(2.5); + expect(await AppSettings.instance.getUiScale(), kMaxUiScale); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.uiScale); }); diff --git a/test/features/settings/interface_scale_slider_test.dart b/test/features/settings/interface_scale_slider_test.dart new file mode 100644 index 00000000..82ceb734 --- /dev/null +++ b/test/features/settings/interface_scale_slider_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('InterfaceScaleSlider', () { + testWidgets('shows percentage and slider', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: InterfaceScaleSlider(scale: 1.0), + ), + ), + ); + await tester.pump(); + + expect(find.text('100%'), findsOneWidget); + expect(find.byType(Slider), findsOneWidget); + }); + + test('preview updates controller without persisting', () { + final controller = UiScaleController.instance; + final before = controller.scale; + controller.setScalePreview(1.15); + expect(controller.scale, closeTo(1.15, 0.001)); + controller.setScalePreview(before); + }); + }); + + group('ui scale range', () { + test('matches Telegram-style 75–200 percent bounds', () { + expect(kMinUiScale, 0.75); + expect(kMaxUiScale, 2.0); + expect(kUiScaleStep, 0.01); + }); + }); +} diff --git a/test/shared/querya_dropdown_test.dart b/test/shared/querya_dropdown_test.dart index 408ae03a..ec2fa07f 100644 --- a/test/shared/querya_dropdown_test.dart +++ b/test/shared/querya_dropdown_test.dart @@ -68,6 +68,33 @@ void main() { expect(picked, 2); }); + testWidgets('menu anchor constrains width to trigger', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 240, + child: QueryaDropdown( + value: 'a', + expandToParent: true, + items: const [ + QueryaDropdownItem(value: 'a', label: 'Alpha'), + QueryaDropdownItem(value: 'b', label: 'Beta'), + ], + onSelected: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(); + + final anchor = tester.widget( + find.byType(material.MenuAnchor), + ); + expect(anchor.crossAxisUnconstrained, isFalse); + }); + testWidgets('shows check on selected menu item', (tester) async { await tester.pumpWidget( queryaThemeTestShell( From 680804cedc8ed75b3644bffb669814b0e43e47b6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 9 Jun 2026 19:00:40 +0300 Subject: [PATCH 4/5] feat(ui): snap interface scale slider to presets, Shift for fine steps (#89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default slider ticks at 75–200% presets; hold Shift for 1% continuous control. --- lib/core/layout/ui_scale_controller.dart | 20 +-- lib/core/storage/app_settings.dart | 42 +++++- .../preferences_appearance_section.dart | 2 +- .../settings/preferences_controls.dart | 123 +++++++++++++----- test/core/storage/app_settings_test.dart | 5 +- .../settings/interface_scale_slider_test.dart | 23 +++- 6 files changed, 164 insertions(+), 51 deletions(-) diff --git a/lib/core/layout/ui_scale_controller.dart b/lib/core/layout/ui_scale_controller.dart index 1ae2c6d8..ce318e9b 100644 --- a/lib/core/layout/ui_scale_controller.dart +++ b/lib/core/layout/ui_scale_controller.dart @@ -15,25 +15,29 @@ class UiScaleController extends ChangeNotifier { } /// Live preview while dragging the scale slider (not persisted). - void setScalePreview(double value) { - final next = _normalizeUiScale(value); + void setScalePreview(double value, {bool fine = false}) { + final next = _normalize(value, fine: fine); if (next == _scale) return; _scale = next; notifyListeners(); } /// Persist scale to SQLite (called on slider release). - Future commitScale(double value) async { - await AppSettings.instance.setUiScale(value); + Future commitScale(double value, {bool fine = false}) async { + await AppSettings.instance.setUiScale(value, fine: fine); _scale = await AppSettings.instance.getUiScale(); notifyListeners(); } - Future setScale(double value) => commitScale(value); + Future setScale(double value, {bool fine = false}) => + commitScale(value, fine: fine); - double _normalizeUiScale(double value) { + double _normalize(double value, {required bool fine}) { final clamped = value.clamp(kMinUiScale, kMaxUiScale); - final steps = ((clamped - kMinUiScale) / kUiScaleStep).round(); - return (kMinUiScale + steps * kUiScaleStep).clamp(kMinUiScale, kMaxUiScale); + if (fine) { + final steps = ((clamped - kMinUiScale) / kUiScaleStep).round(); + return (kMinUiScale + steps * kUiScaleStep).clamp(kMinUiScale, kMaxUiScale); + } + return snapUiScaleToPreset(clamped); } } diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 04fa7390..ad8036c2 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -31,15 +31,47 @@ const double kMinUiScale = 0.75; /// Maximum interface scale (Telegram goes to 300%; 200% is enough for Querya). const double kMaxUiScale = 2.0; -/// Slider step — 1% increments, like Telegram's interface scale control. +/// Slider step — 1% increments when Shift is held (fine control). const double kUiScaleStep = 0.01; -double _normalizeUiScale(double value) { +/// Fixed tick marks on the interface scale slider (75% … 200%). +const List kUiScalePresets = [ + 0.75, + 0.85, + 0.9, + 1.0, + 1.1, + 1.25, + 1.5, + 1.75, + 2.0, +]; + +int nearestUiScalePresetIndex(double scale) { + var best = 0; + var bestDist = double.infinity; + for (var i = 0; i < kUiScalePresets.length; i++) { + final dist = (kUiScalePresets[i] - scale).abs(); + if (dist < bestDist) { + bestDist = dist; + best = i; + } + } + return best; +} + +double snapUiScaleToPreset(double scale) => + kUiScalePresets[nearestUiScalePresetIndex(scale)]; + +double _normalizeUiScaleContinuous(double value) { final clamped = value.clamp(kMinUiScale, kMaxUiScale); final steps = ((clamped - kMinUiScale) / kUiScaleStep).round(); return (kMinUiScale + steps * kUiScaleStep).clamp(kMinUiScale, kMaxUiScale); } +double _normalizeUiScale(double value, {bool fine = false}) => + fine ? _normalizeUiScaleContinuous(value) : snapUiScaleToPreset(value); + /// Default cap on stored SQL history entries per connection + database. const int kDefaultSqlHistoryMaxEntries = 100; @@ -184,11 +216,11 @@ class AppSettings { if (v == null || v.isEmpty) return kDefaultUiScale; final n = double.tryParse(v); if (n == null) return kDefaultUiScale; - return _normalizeUiScale(n); + return _normalizeUiScaleContinuous(n); } - Future setUiScale(double scale) async { - final normalized = _normalizeUiScale(scale); + Future setUiScale(double scale, {bool fine = false}) async { + final normalized = _normalizeUiScale(scale, fine: fine); await LocalDb.instance.setAppSetting( AppSettingsKeys.uiScale, normalized.toStringAsFixed(2), diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 3c1c3645..7c464a92 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -156,7 +156,7 @@ class _PreferencesAppearanceSectionState PreferencesFieldRow( label: 'Interface scale', hint: - 'Drag to resize the UI (75–200%). Changes apply live; release to save.', + 'Snap to presets (75%, 85%, 90%, 100% …). Hold Shift for 1% fine control.', control: InterfaceScaleSlider(scale: _uiScale.scale), ), const material.SizedBox(height: 12), diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index e976d36d..a72022ce 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -1,6 +1,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; @@ -75,52 +76,106 @@ class PreferencesFieldRow extends StatelessWidget { } } -/// Telegram-style interface scale slider (75–200%, 1% steps, live preview). -class InterfaceScaleSlider extends StatelessWidget { +/// Interface scale slider: fixed presets by default; hold Shift for 1% fine steps. +class InterfaceScaleSlider extends material.StatefulWidget { const InterfaceScaleSlider({super.key, required this.scale}); final double scale; - static int get _divisions => + @override + material.State createState() => + _InterfaceScaleSliderState(); +} + +class _InterfaceScaleSliderState extends material.State { + bool _fineControl = false; + + static int get _fineDivisions => ((kMaxUiScale - kMinUiScale) / kUiScaleStep).round(); + bool get _shiftHeld => HardwareKeyboard.instance.isShiftPressed; + + void _syncModifierKeys() { + final fine = _shiftHeld; + if (fine != _fineControl) { + setState(() => _fineControl = fine); + } + } + + double _sliderPosition(double scale, {required bool fine}) { + if (fine) return scale; + return nearestUiScalePresetIndex(scale).toDouble(); + } + + double _scaleFromSlider(double position, {required bool fine}) { + if (fine) return position; + final index = position.round().clamp(0, kUiScalePresets.length - 1); + return kUiScalePresets[index]; + } + @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; - final pct = (scale * 100).round(); + final pct = (widget.scale * 100).round(); + final fine = _fineControl; - return material.Row( - children: [ - material.Expanded( - child: Slider( - value: SliderValue.single(scale), - min: kMinUiScale, - max: kMaxUiScale, - divisions: _divisions, - hintValue: const SliderValue.single(kDefaultUiScale), - onChanged: (value) { - UiScaleController.instance.setScalePreview(value.value); - }, - onChangeEnd: (value) { - unawaited(UiScaleController.instance.commitScale(value.value)); - }, - ), - ), - const material.SizedBox(width: 8), - material.SizedBox( - width: 44, - child: material.Text( - '$pct%', - textAlign: material.TextAlign.right, - style: material.TextStyle( - fontSize: 13, - fontWeight: material.FontWeight.w600, - color: cs.popoverForeground, + return material.Listener( + onPointerDown: (_) => _syncModifierKeys(), + onPointerMove: (_) => _syncModifierKeys(), + child: material.Row( + children: [ + material.Expanded( + child: Slider( + value: SliderValue.single( + _sliderPosition(widget.scale, fine: fine), + ), + min: fine ? kMinUiScale : 0, + max: fine + ? kMaxUiScale + : (kUiScalePresets.length - 1).toDouble(), + divisions: fine ? _fineDivisions : kUiScalePresets.length - 1, + hintValue: const SliderValue.single(kDefaultUiScale), + onChanged: (value) { + _syncModifierKeys(); + final next = _scaleFromSlider( + value.value, + fine: _shiftHeld, + ); + UiScaleController.instance.setScalePreview( + next, + fine: _shiftHeld, + ); + }, + onChangeEnd: (value) { + final next = _scaleFromSlider( + value.value, + fine: _shiftHeld, + ); + unawaited( + UiScaleController.instance.commitScale( + next, + fine: _shiftHeld, + ), + ); + }, + ), ), - ), + const material.SizedBox(width: 8), + material.SizedBox( + width: 44, + child: material.Text( + '$pct%', + textAlign: material.TextAlign.right, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.popoverForeground, + ), + ), + ), + ], ), - ], - ); + ); } } diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index dfeba43d..cb0d5f6a 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -233,7 +233,7 @@ void main() { test('defaults to 1.0 and stores continuous 1% steps', () async { expect(await AppSettings.instance.getUiScale(), kDefaultUiScale); - await AppSettings.instance.setUiScale(1.12); + await AppSettings.instance.setUiScale(1.12, fine: true); expect(await AppSettings.instance.getUiScale(), closeTo(1.12, 0.001)); await AppSettings.instance.setUiScale(0.75); @@ -242,6 +242,9 @@ void main() { await AppSettings.instance.setUiScale(2.5); expect(await AppSettings.instance.getUiScale(), kMaxUiScale); + await AppSettings.instance.setUiScale(1.12, fine: false); + expect(await AppSettings.instance.getUiScale(), 1.1); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.uiScale); }); }); diff --git a/test/features/settings/interface_scale_slider_test.dart b/test/features/settings/interface_scale_slider_test.dart index 82ceb734..7e346486 100644 --- a/test/features/settings/interface_scale_slider_test.dart +++ b/test/features/settings/interface_scale_slider_test.dart @@ -23,12 +23,14 @@ void main() { expect(find.byType(Slider), findsOneWidget); }); - test('preview updates controller without persisting', () { + test('preview snaps to presets unless fine mode', () { final controller = UiScaleController.instance; final before = controller.scale; controller.setScalePreview(1.15); + expect(controller.scale, 1.1); + controller.setScalePreview(1.15, fine: true); expect(controller.scale, closeTo(1.15, 0.001)); - controller.setScalePreview(before); + controller.setScalePreview(before, fine: true); }); }); @@ -38,5 +40,22 @@ void main() { expect(kMaxUiScale, 2.0); expect(kUiScaleStep, 0.01); }); + + test('presets snap to fixed ticks', () { + expect(kUiScalePresets, [ + 0.75, + 0.85, + 0.9, + 1.0, + 1.1, + 1.25, + 1.5, + 1.75, + 2.0, + ]); + expect(snapUiScaleToPreset(1.12), 1.1); + expect(snapUiScaleToPreset(0.88), 0.9); + expect(nearestUiScalePresetIndex(1.0), 3); + }); }); } From 3dc5d42b55201e8785b24bc7363660c6f3e19294 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 9 Jun 2026 19:22:04 +0300 Subject: [PATCH 5/5] feat(ui): scale modal dialog dimensions with interface scale (#89) Add WindowLayout.dialogConstraints and apply UI scale to all app modals including connection forms, preferences, and SQL editor dialogs. --- lib/core/layout/window_layout.dart | 71 +++++++++++++++---- .../connections/driver_manager_dialog.dart | 6 +- .../connections/new_connection_dialog.dart | 9 ++- .../connections/new_folder_dialog.dart | 6 +- .../main_screen/sql_query_history_dialog.dart | 3 +- .../mongodb/mongo_database_dialog.dart | 2 +- .../mongodb/mongodb_connection_form.dart | 6 +- lib/features/mysql/mysql_connection_form.dart | 6 +- .../mysql/mysql_sql_editor_dialog.dart | 3 +- .../postgres_sql_editor_dialog.dart | 3 +- .../postgres_table_privileges_dialog.dart | 23 ++++-- .../postgresql_connection_form.dart | 6 +- lib/features/redis/redis_connection_form.dart | 6 +- lib/features/settings/preferences_dialog.dart | 3 +- .../core/layout/window_layout_scale_test.dart | 54 ++++++++++++++ 15 files changed, 173 insertions(+), 34 deletions(-) create mode 100644 test/core/layout/window_layout_scale_test.dart diff --git a/lib/core/layout/window_layout.dart b/lib/core/layout/window_layout.dart index a926d607..7491d6d8 100644 --- a/lib/core/layout/window_layout.dart +++ b/lib/core/layout/window_layout.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/widgets.dart'; +import 'package:querya_desktop/core/layout/ui_scale.dart'; /// Breakpoints and sizes derived from window / overlay size (desktop adaptive UI). abstract class WindowLayout { @@ -18,6 +19,36 @@ abstract class WindowLayout { return (screenHeight * 0.04).clamp(12.0, 40.0); } + /// Scaled [BoxConstraints] for modal dialogs (respects [QueryaUiScaleScope]). + static BoxConstraints dialogConstraints( + BuildContext context, { + double? maxWidth, + double? minWidth, + double? maxHeight, + double? minHeight, + }) { + return BoxConstraints( + maxWidth: maxWidth != null ? context.scaled(maxWidth) : double.infinity, + minWidth: minWidth != null ? context.scaled(minWidth) : 0, + maxHeight: maxHeight != null ? context.scaled(maxHeight) : double.infinity, + minHeight: minHeight != null ? context.scaled(minHeight) : 0, + ); + } + + /// Fits a base dialog dimension into the viewport, then applies UI scale. + static double scaledDialogExtent( + BuildContext context, { + required double screenExtent, + required double insetTotal, + required double baseMax, + required double baseMin, + double viewportFactor = 1.0, + }) { + final available = math.max(0.0, screenExtent - insetTotal); + final base = math.min(baseMax, math.max(baseMin, available * viewportFactor)); + return math.min(context.scaled(base), available); + } + /// Use for [Dialog.insetPadding] / modal margins on small windows. static EdgeInsets dialogSymmetricInsets(BuildContext context) { final mq = MediaQuery.sizeOf(context); @@ -28,15 +59,30 @@ abstract class WindowLayout { } /// "Select database" and similar pickers. - static double newConnectionDialogMaxWidth(double screenWidth) { - final inset = dialogHorizontalInset(screenWidth) * 2; - return math.min(740, math.max(280.0, screenWidth - inset)); + static double newConnectionDialogMaxWidth(BuildContext context) { + final mq = MediaQuery.sizeOf(context); + final inset = dialogHorizontalInset(mq.width) * 2; + return scaledDialogExtent( + context, + screenExtent: mq.width, + insetTotal: inset, + baseMax: 740, + baseMin: 280, + viewportFactor: 1.0, + ); } - static double newConnectionDialogHeight(double screenHeight) { - final inset = dialogVerticalInset(screenHeight) * 2; - final h = screenHeight - inset; - return math.min(580, math.max(320.0, h * 0.78)); + static double newConnectionDialogHeight(BuildContext context) { + final mq = MediaQuery.sizeOf(context); + final inset = dialogVerticalInset(mq.height) * 2; + return scaledDialogExtent( + context, + screenExtent: mq.height, + insetTotal: inset, + baseMax: 580, + baseMin: 320, + viewportFactor: 0.78, + ); } static double newConnectionSidebarWidth(double dialogWidth) { @@ -53,12 +99,13 @@ abstract class WindowLayout { return 1; } - static double dbTypeCardHeight(int crossAxisCount) { - return switch (crossAxisCount) { - 4 => 144, - 2 => 138, - _ => 132, + static double dbTypeCardHeight(BuildContext context, int crossAxisCount) { + final base = switch (crossAxisCount) { + 4 => 144.0, + 2 => 138.0, + _ => 132.0, }; + return context.scaled(base); } /// Empty workspace hero content max width (stays within viewport minus padding). diff --git a/lib/features/connections/driver_manager_dialog.dart b/lib/features/connections/driver_manager_dialog.dart index 8b77c30f..b3e18e19 100644 --- a/lib/features/connections/driver_manager_dialog.dart +++ b/lib/features/connections/driver_manager_dialog.dart @@ -51,7 +51,11 @@ class _DriverManagerDialogContent extends material.StatelessWidget { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints(maxWidth: 520, minWidth: 400), + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 520, + minWidth: 400, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index 6bd23e8c..9cf93f7e 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -89,9 +89,8 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; - final mq = MediaQuery.sizeOf(context); - final dialogMaxW = WindowLayout.newConnectionDialogMaxWidth(mq.width); - final dialogH = WindowLayout.newConnectionDialogHeight(mq.height); + final dialogMaxW = WindowLayout.newConnectionDialogMaxWidth(context); + final dialogH = WindowLayout.newConnectionDialogHeight(context); final headerPadH = dialogMaxW < 420 ? 16.0 : 24.0; final stackFilters = dialogMaxW < 520; @@ -185,8 +184,8 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial final innerW = math.max(0.0, constraints.maxWidth - gridPad * 2); final crossAxisCount = WindowLayout.dbTypeGridCrossAxisCount(innerW); - final cardHeight = - WindowLayout.dbTypeCardHeight(crossAxisCount); + final cardHeight = + WindowLayout.dbTypeCardHeight(context, crossAxisCount); final cardWidth = crossAxisCount > 0 ? (innerW - spacing * (crossAxisCount - 1)) / crossAxisCount : innerW; diff --git a/lib/features/connections/new_folder_dialog.dart b/lib/features/connections/new_folder_dialog.dart index 15c95a5d..db316e78 100644 --- a/lib/features/connections/new_folder_dialog.dart +++ b/lib/features/connections/new_folder_dialog.dart @@ -37,7 +37,11 @@ class _NewFolderDialogContentState extends material.State<_NewFolderDialogConten final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints(maxWidth: 440, minWidth: 360), + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 440, + minWidth: 360, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), diff --git a/lib/features/main_screen/sql_query_history_dialog.dart b/lib/features/main_screen/sql_query_history_dialog.dart index c9e01a18..bf5a1d98 100644 --- a/lib/features/main_screen/sql_query_history_dialog.dart +++ b/lib/features/main_screen/sql_query_history_dialog.dart @@ -125,7 +125,8 @@ class _SqlQueryHistoryDialogContentState final scheme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints( + constraints: WindowLayout.dialogConstraints( + context, maxWidth: 520, minWidth: 320, maxHeight: 440, diff --git a/lib/features/mongodb/mongo_database_dialog.dart b/lib/features/mongodb/mongo_database_dialog.dart index dcf15582..620de32f 100644 --- a/lib/features/mongodb/mongo_database_dialog.dart +++ b/lib/features/mongodb/mongo_database_dialog.dart @@ -52,7 +52,7 @@ class _CreateMongoDBDialogContentState extends material.State<_CreateMongoDBDial final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints(maxWidth: 500), + constraints: WindowLayout.dialogConstraints(context, maxWidth: 500), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index f0526ece..0382846f 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -207,7 +207,11 @@ class _MongoConnectionFormContentState extends material.State<_MongoConnectionFo final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints(maxWidth: 600, maxHeight: 700), + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 600, + maxHeight: 700, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 8227115f..525215b9 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -186,7 +186,11 @@ class _MysqlConnectionFormContentState return material.Container( constraints: - const material.BoxConstraints(maxWidth: 600, maxHeight: 640), + WindowLayout.dialogConstraints( + context, + maxWidth: 600, + maxHeight: 640, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), diff --git a/lib/features/mysql/mysql_sql_editor_dialog.dart b/lib/features/mysql/mysql_sql_editor_dialog.dart index 6fbdcda3..3f653e3d 100644 --- a/lib/features/mysql/mysql_sql_editor_dialog.dart +++ b/lib/features/mysql/mysql_sql_editor_dialog.dart @@ -78,7 +78,8 @@ class _MysqlSqlEditorDialogState extends material.State<_MysqlSqlEditorDialog> { backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), child: material.Container( - constraints: const material.BoxConstraints( + constraints: WindowLayout.dialogConstraints( + context, maxWidth: 720, minWidth: 480, maxHeight: 520, diff --git a/lib/features/postgresql/postgres_sql_editor_dialog.dart b/lib/features/postgresql/postgres_sql_editor_dialog.dart index 8c05490f..f0e5a84a 100644 --- a/lib/features/postgresql/postgres_sql_editor_dialog.dart +++ b/lib/features/postgresql/postgres_sql_editor_dialog.dart @@ -105,7 +105,8 @@ class _PostgresSqlEditorDialogState extends material.State<_PostgresSqlEditorDia backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), child: material.Container( - constraints: const material.BoxConstraints( + constraints: WindowLayout.dialogConstraints( + context, maxWidth: 720, minWidth: 480, maxHeight: 520, diff --git a/lib/features/postgresql/postgres_table_privileges_dialog.dart b/lib/features/postgresql/postgres_table_privileges_dialog.dart index b66dbc2a..c5234604 100644 --- a/lib/features/postgresql/postgres_table_privileges_dialog.dart +++ b/lib/features/postgresql/postgres_table_privileges_dialog.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; @@ -81,10 +79,23 @@ class _PrivilegesDialogBodyState extends material.State<_PrivilegesDialogBody> { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; final mq = material.MediaQuery.sizeOf(context); - final dialogHeight = - math.min(480.0, math.max(260.0, mq.height * 0.72)).toDouble(); - final dialogWidth = - math.min(560.0, math.max(300.0, mq.width - 48)).toDouble(); + final hInset = WindowLayout.dialogVerticalInset(mq.height) * 2; + final wInset = WindowLayout.dialogHorizontalInset(mq.width) * 2; + final dialogHeight = WindowLayout.scaledDialogExtent( + context, + screenExtent: mq.height, + insetTotal: hInset, + baseMax: 480, + baseMin: 260, + viewportFactor: 0.72, + ); + final dialogWidth = WindowLayout.scaledDialogExtent( + context, + screenExtent: mq.width, + insetTotal: wInset, + baseMax: 560, + baseMin: 300, + ); return material.Dialog( backgroundColor: material.Colors.transparent, diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 30ab6670..06119d65 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -186,7 +186,11 @@ class _PostgresConnectionFormContentState return material.Container( constraints: - const material.BoxConstraints(maxWidth: 600, maxHeight: 640), + WindowLayout.dialogConstraints( + context, + maxWidth: 600, + maxHeight: 640, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index 798b3a2c..e2dbb48e 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -137,7 +137,11 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints(maxWidth: 600, maxHeight: 560), + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 600, + maxHeight: 560, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 664b48bb..78fc0762 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -94,7 +94,8 @@ class _PreferencesDialogContentState child: material.IconTheme( data: material.IconThemeData(color: onPopover), child: material.Container( - constraints: const material.BoxConstraints( + constraints: WindowLayout.dialogConstraints( + context, maxWidth: 480, minWidth: 360, maxHeight: 640, diff --git a/test/core/layout/window_layout_scale_test.dart b/test/core/layout/window_layout_scale_test.dart new file mode 100644 index 00000000..f064d83e --- /dev/null +++ b/test/core/layout/window_layout_scale_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; + +void main() { + testWidgets('dialogConstraints scales with QueryaUiScaleScope', (tester) async { + await tester.pumpWidget( + QueryaUiScaleScope( + scale: 1.25, + child: MaterialApp( + home: Builder( + builder: (context) { + final c = WindowLayout.dialogConstraints( + context, + maxWidth: 480, + minWidth: 360, + maxHeight: 640, + ); + expect(c.maxWidth, 600); + expect(c.minWidth, 450); + expect(c.maxHeight, 800); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + }); + + testWidgets('scaledDialogExtent applies scale before viewport clamp', (tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => QueryaUiScaleScope( + scale: 1.25, + child: child ?? const SizedBox.shrink(), + ), + home: Builder( + builder: (context) { + final extent = WindowLayout.scaledDialogExtent( + context, + screenExtent: 2000, + insetTotal: 100, + baseMax: 480, + baseMin: 200, + ); + expect(extent, 600); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); +}