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..ce318e9b --- /dev/null +++ b/lib/core/layout/ui_scale_controller.dart @@ -0,0 +1,43 @@ +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(); + } + + /// Live preview while dragging the scale slider (not persisted). + 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, {bool fine = false}) async { + await AppSettings.instance.setUiScale(value, fine: fine); + _scale = await AppSettings.instance.getUiScale(); + notifyListeners(); + } + + Future setScale(double value, {bool fine = false}) => + commitScale(value, fine: fine); + + double _normalize(double value, {required bool fine}) { + final clamped = value.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/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/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 77c537bf..ad8036c2 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -22,6 +22,56 @@ 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; + +/// 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 when Shift is held (fine control). +const double kUiScaleStep = 0.01; + +/// 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; @@ -57,6 +107,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 +210,24 @@ 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 _normalizeUiScaleContinuous(n); + } + + Future setUiScale(double scale, {bool fine = false}) async { + final normalized = _normalizeUiScale(scale, fine: fine); + await LocalDb.instance.setAppSetting( + AppSettingsKeys.uiScale, + normalized.toStringAsFixed(2), + ); + 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/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_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 84fe0961..7c464a92 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -2,12 +2,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/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'; /// Appearance / theme controls for [PreferencesDialog]. class PreferencesAppearanceSection extends material.StatefulWidget { @@ -21,6 +21,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 +29,13 @@ class _PreferencesAppearanceSectionState void initState() { super.initState(); _controller.addListener(_onThemeChanged); + _uiScale.addListener(_onThemeChanged); } @override void dispose() { _controller.removeListener(_onThemeChanged); + _uiScale.removeListener(_onThemeChanged); super.dispose(); } @@ -101,77 +104,79 @@ 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: + 'Snap to presets (75%, 85%, 90%, 100% …). Hold Shift for 1% fine control.', + control: InterfaceScaleSlider(scale: _uiScale.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..a72022ce 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -1,4 +1,9 @@ +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'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -22,6 +27,158 @@ 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!), + ), + ], + ], + ); + } +} + +/// 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; + + @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 = (widget.scale * 100).round(); + final fine = _fineControl; + + 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, + ), + ), + ), + ], + ), + ); + } +} + /// Preferences dropdown backed by [QueryaDropdown] ([MenuAnchor]). class PreferencesDropdownMenu extends StatelessWidget { const PreferencesDropdownMenu({ @@ -30,7 +187,7 @@ class PreferencesDropdownMenu extends StatelessWidget { required this.entries, required this.onSelected, this.width, - this.expandToParent = false, + this.expandToParent = true, this.enabled = true, }); @@ -38,10 +195,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..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, @@ -144,17 +145,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 +159,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 +173,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 d6723e79..3480ec79 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -1,6 +1,10 @@ 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'; +export 'querya_dropdown_tokens.dart'; + /// One selectable row in [QueryaDropdown]. class QueryaDropdownItem { const QueryaDropdownItem({ @@ -18,7 +22,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 +33,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 +55,7 @@ class QueryaDropdown extends material.StatefulWidget { class _QueryaDropdownState extends material.State> { late material.MenuController _controller; + bool _triggerHovered = false; @override void initState() { @@ -66,19 +71,19 @@ class _QueryaDropdownState extends material.State> { } } - material.Widget _triggerLabel({ + 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: 14, - color: widget.enabled ? cs.popoverForeground : cs.mutedForeground, - ), + style: QueryaDropdownTokens.triggerTextStyle(context, textColor), ); if (expand) { return material.Expanded(child: text); @@ -94,57 +99,135 @@ 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 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 + ? 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, ), - ), - 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, + curve: material.Curves.easeOut, + height: triggerHeight, + padding: QueryaDropdownTokens.scaledTriggerPadding(context), + decoration: material.BoxDecoration( + color: _triggerHovered + ? 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, + children: [ + _triggerLabelText( + context: context, + label: label, + cs: cs, + expand: widget.expandToParent, + ), + material.SizedBox(width: chevronGap), + material.Icon( + material.Icons.keyboard_arrow_down_rounded, + size: chevronSize, + color: widget.enabled + ? cs.mutedForeground + : cs.mutedForeground.withValues(alpha: 0.5), + ), + ], ), ), ); + + 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(radius), + child: fieldWidth != null + ? material.SizedBox(width: fieldWidth, child: triggerBody) + : triggerBody, + ), + ); } @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 fieldWidth = widget.expandToParent + ? null + : (widget.width != null ? context.scaled(widget.width!) : null); final menuChildren = widget.items.map((item) => _menuItem(item, cs)).toList(); + 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, + crossAxisUnconstrained: false, + alignmentOffset: material.Offset( + widget.alignmentOffset.dx, + context.scaled(widget.alignmentOffset.dy), + ), consumeOutsideTap: true, 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( @@ -155,50 +238,17 @@ class _QueryaDropdownState extends material.State> { ), 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 +258,110 @@ 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(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: checkSize, + color: cs.primary, + ); + } + if (widget.item.leading != null) { + return widget.item.leading!; + } + return material.SizedBox(width: slot); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = widget.colorScheme; + final bg = _hovered + ? cs.accent.withValues(alpha: 0.14) + : widget.selected + ? cs.muted.withValues(alpha: 0.32) + : material.Colors.transparent; + final itemHeight = QueryaDropdownTokens.scaledMenuItemHeight(context); + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + final slot = context.scaled(QueryaDropdownTokens.selectedCheckSlotWidth); + + 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: material.Size(double.infinity, 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(radius), + ), + ), + onPressed: widget.enabled ? widget.onPick : null, + child: material.AnimatedContainer( + duration: const Duration( + milliseconds: QueryaDropdownTokens.hoverAnimationMs, + ), + curve: material.Curves.easeOut, + 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(radius), + ), + child: material.Row( + children: [ + 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: 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 new file mode 100644 index 00000000..367bbaaf --- /dev/null +++ b/lib/shared/widgets/querya_dropdown_tokens.dart @@ -0,0 +1,91 @@ +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 (content is vertically centered). + static const double triggerHeight = 36.0; + + static const double triggerPaddingHorizontal = 12.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 material.Color menuShadowColor = material.Color(0x42000000); + + static const material.EdgeInsets menuPadding = + material.EdgeInsets.symmetric(vertical: 4.0, horizontal: 4.0); + + static const double menuItemHeight = 32.0; + + static const material.EdgeInsets menuItemPadding = + material.EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.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 12750aeb..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'; +export 'querya_dropdown.dart' + show QueryaDropdown, QueryaDropdownItem, QueryaDropdownTokens, kPreferencesLabelWidth; export 'package:shadcn_flutter/shadcn_flutter.dart'; 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(); + }, + ), + ), + ); + }); +} diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 5b1a0d99..cb0d5f6a 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -229,6 +229,26 @@ void main() { }); }); + group('ui scale', () { + test('defaults to 1.0 and stores continuous 1% steps', () async { + expect(await AppSettings.instance.getUiScale(), kDefaultUiScale); + + await AppSettings.instance.setUiScale(1.12, fine: true); + expect(await AppSettings.instance.getUiScale(), closeTo(1.12, 0.001)); + + 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 AppSettings.instance.setUiScale(1.12, fine: false); + expect(await AppSettings.instance.getUiScale(), 1.1); + + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.uiScale); + }); + }); + group('AppSettingsRevision', () { test('bump increments listenable value', () { final start = AppSettingsRevision.listenable.value; 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..7e346486 --- /dev/null +++ b/test/features/settings/interface_scale_slider_test.dart @@ -0,0 +1,61 @@ +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 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, fine: true); + }); + }); + + group('ui scale range', () { + test('matches Telegram-style 75–200 percent bounds', () { + expect(kMinUiScale, 0.75); + 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); + }); + }); +} diff --git a/test/shared/querya_dropdown_test.dart b/test/shared/querya_dropdown_test.dart index 0f1a9c58..ec2fa07f 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, 36.0); + expect(QueryaDropdownTokens.menuAlignmentOffset, const material.Offset(0, 4)); + expect(QueryaDropdownTokens.menuMaxHeight, 300.0); + expect(QueryaDropdownTokens.menuBorderRadius, 6.0); + expect(QueryaDropdownTokens.fontSize, 14.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,56 @@ 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( + 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( 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, );