Skip to content

Commit bd04530

Browse files
Merge pull request #90 from QueryaHub/issue/89-querya-dropdown-design-system
feat(ui): дизайн-система QueryaDropdown, interface scale и модалки (#89)
2 parents 25c6879 + 2c631de commit bd04530

31 files changed

Lines changed: 1137 additions & 315 deletions

.flutter-plugins-dependencies

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

lib/app/app.dart

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'package:querya_desktop/core/layout/ui_scale.dart';
2+
import 'package:querya_desktop/core/layout/ui_scale_controller.dart';
13
import 'package:querya_desktop/core/theme/querya_material_theme.dart';
24
import 'package:querya_desktop/core/theme/querya_theme_scope.dart';
35
import 'package:querya_desktop/core/theme/theme_controller.dart';
@@ -13,11 +15,14 @@ class QueryaApp extends StatelessWidget {
1315
Widget build(BuildContext context) {
1416
final themeController = ThemeController.instance;
1517

18+
final uiScaleController = UiScaleController.instance;
19+
1620
return ListenableBuilder(
17-
listenable: themeController,
21+
listenable: Listenable.merge([themeController, uiScaleController]),
1822
builder: (context, _) {
1923
final queryaTheme = themeController.activeTheme;
2024
final colorScheme = queryaTheme.colorScheme;
25+
final scale = uiScaleController.scale;
2126
return ShadcnApp(
2227
title: 'Querya',
2328
theme: themeController.lightShadcnTheme,
@@ -28,10 +33,21 @@ class QueryaApp extends StatelessWidget {
2833
enableThemeAnimation: themeController.themeAnimationEnabled,
2934
enableScrollInterception: false,
3035
// Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens.
31-
builder: (context, child) => QueryaThemeScope(
32-
data: queryaTheme,
33-
child: child ?? const SizedBox.shrink(),
34-
),
36+
builder: (context, child) {
37+
final mq = MediaQuery.maybeOf(context);
38+
return QueryaUiScaleScope(
39+
scale: scale,
40+
child: MediaQuery(
41+
data: (mq ?? const MediaQueryData()).copyWith(
42+
textScaler: TextScaler.linear(scale),
43+
),
44+
child: QueryaThemeScope(
45+
data: queryaTheme,
46+
child: child ?? const SizedBox.shrink(),
47+
),
48+
),
49+
);
50+
},
3551
home: const AppLifecycleCleanup(
3652
child: MainScreen(),
3753
),

lib/core/layout/ui_scale.dart

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import 'package:flutter/widgets.dart';
2+
3+
/// App-wide UI scale factor (Preferences → Appearance).
4+
class QueryaUiScaleScope extends InheritedWidget {
5+
const QueryaUiScaleScope({
6+
super.key,
7+
required this.scale,
8+
required super.child,
9+
});
10+
11+
final double scale;
12+
13+
static double of(BuildContext context) {
14+
return context
15+
.dependOnInheritedWidgetOfExactType<QueryaUiScaleScope>()
16+
?.scale ??
17+
1.0;
18+
}
19+
20+
@override
21+
bool updateShouldNotify(QueryaUiScaleScope oldWidget) =>
22+
oldWidget.scale != scale;
23+
}
24+
25+
extension QueryaUiScaleContext on BuildContext {
26+
double get uiScale => QueryaUiScaleScope.of(this);
27+
28+
double scaled(double logicalPixels) => logicalPixels * uiScale;
29+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import 'package:flutter/foundation.dart';
2+
import 'package:querya_desktop/core/storage/app_settings.dart';
3+
4+
/// Loads and broadcasts [AppSettings] UI scale for the widget tree.
5+
class UiScaleController extends ChangeNotifier {
6+
UiScaleController._();
7+
static final UiScaleController instance = UiScaleController._();
8+
9+
double _scale = kDefaultUiScale;
10+
double get scale => _scale;
11+
12+
Future<void> load() async {
13+
_scale = await AppSettings.instance.getUiScale();
14+
notifyListeners();
15+
}
16+
17+
/// Live preview while dragging the scale slider (not persisted).
18+
void setScalePreview(double value, {bool fine = false}) {
19+
final next = _normalize(value, fine: fine);
20+
if (next == _scale) return;
21+
_scale = next;
22+
notifyListeners();
23+
}
24+
25+
/// Persist scale to SQLite (called on slider release).
26+
Future<void> commitScale(double value, {bool fine = false}) async {
27+
await AppSettings.instance.setUiScale(value, fine: fine);
28+
_scale = await AppSettings.instance.getUiScale();
29+
notifyListeners();
30+
}
31+
32+
Future<void> setScale(double value, {bool fine = false}) =>
33+
commitScale(value, fine: fine);
34+
35+
double _normalize(double value, {required bool fine}) {
36+
final clamped = value.clamp(kMinUiScale, kMaxUiScale);
37+
if (fine) {
38+
final steps = ((clamped - kMinUiScale) / kUiScaleStep).round();
39+
return (kMinUiScale + steps * kUiScaleStep).clamp(kMinUiScale, kMaxUiScale);
40+
}
41+
return snapUiScaleToPreset(clamped);
42+
}
43+
}

lib/core/layout/window_layout.dart

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:math' as math;
22

33
import 'package:flutter/widgets.dart';
4+
import 'package:querya_desktop/core/layout/ui_scale.dart';
45

56
/// Breakpoints and sizes derived from window / overlay size (desktop adaptive UI).
67
abstract class WindowLayout {
@@ -18,6 +19,36 @@ abstract class WindowLayout {
1819
return (screenHeight * 0.04).clamp(12.0, 40.0);
1920
}
2021

22+
/// Scaled [BoxConstraints] for modal dialogs (respects [QueryaUiScaleScope]).
23+
static BoxConstraints dialogConstraints(
24+
BuildContext context, {
25+
double? maxWidth,
26+
double? minWidth,
27+
double? maxHeight,
28+
double? minHeight,
29+
}) {
30+
return BoxConstraints(
31+
maxWidth: maxWidth != null ? context.scaled(maxWidth) : double.infinity,
32+
minWidth: minWidth != null ? context.scaled(minWidth) : 0,
33+
maxHeight: maxHeight != null ? context.scaled(maxHeight) : double.infinity,
34+
minHeight: minHeight != null ? context.scaled(minHeight) : 0,
35+
);
36+
}
37+
38+
/// Fits a base dialog dimension into the viewport, then applies UI scale.
39+
static double scaledDialogExtent(
40+
BuildContext context, {
41+
required double screenExtent,
42+
required double insetTotal,
43+
required double baseMax,
44+
required double baseMin,
45+
double viewportFactor = 1.0,
46+
}) {
47+
final available = math.max(0.0, screenExtent - insetTotal);
48+
final base = math.min(baseMax, math.max(baseMin, available * viewportFactor));
49+
return math.min(context.scaled(base), available);
50+
}
51+
2152
/// Use for [Dialog.insetPadding] / modal margins on small windows.
2253
static EdgeInsets dialogSymmetricInsets(BuildContext context) {
2354
final mq = MediaQuery.sizeOf(context);
@@ -28,15 +59,30 @@ abstract class WindowLayout {
2859
}
2960

3061
/// "Select database" and similar pickers.
31-
static double newConnectionDialogMaxWidth(double screenWidth) {
32-
final inset = dialogHorizontalInset(screenWidth) * 2;
33-
return math.min(740, math.max(280.0, screenWidth - inset));
62+
static double newConnectionDialogMaxWidth(BuildContext context) {
63+
final mq = MediaQuery.sizeOf(context);
64+
final inset = dialogHorizontalInset(mq.width) * 2;
65+
return scaledDialogExtent(
66+
context,
67+
screenExtent: mq.width,
68+
insetTotal: inset,
69+
baseMax: 740,
70+
baseMin: 280,
71+
viewportFactor: 1.0,
72+
);
3473
}
3574

36-
static double newConnectionDialogHeight(double screenHeight) {
37-
final inset = dialogVerticalInset(screenHeight) * 2;
38-
final h = screenHeight - inset;
39-
return math.min(580, math.max(320.0, h * 0.78));
75+
static double newConnectionDialogHeight(BuildContext context) {
76+
final mq = MediaQuery.sizeOf(context);
77+
final inset = dialogVerticalInset(mq.height) * 2;
78+
return scaledDialogExtent(
79+
context,
80+
screenExtent: mq.height,
81+
insetTotal: inset,
82+
baseMax: 580,
83+
baseMin: 320,
84+
viewportFactor: 0.78,
85+
);
4086
}
4187

4288
static double newConnectionSidebarWidth(double dialogWidth) {
@@ -53,12 +99,13 @@ abstract class WindowLayout {
5399
return 1;
54100
}
55101

56-
static double dbTypeCardHeight(int crossAxisCount) {
57-
return switch (crossAxisCount) {
58-
4 => 144,
59-
2 => 138,
60-
_ => 132,
102+
static double dbTypeCardHeight(BuildContext context, int crossAxisCount) {
103+
final base = switch (crossAxisCount) {
104+
4 => 144.0,
105+
2 => 138.0,
106+
_ => 132.0,
61107
};
108+
return context.scaled(base);
62109
}
63110

64111
/// Empty workspace hero content max width (stays within viewport minus padding).

lib/core/storage/app_settings.dart

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,56 @@ const List<int> kSqlResultMaxRowsPresets = [
2222
/// Default monospace size in the SQL editor (logical pixels).
2323
const double kDefaultSqlEditorFontSize = 13;
2424

25+
/// Default interface scale (1.0 = 100%).
26+
const double kDefaultUiScale = 1.0;
27+
28+
/// Minimum interface scale (Telegram Desktop supports 75%).
29+
const double kMinUiScale = 0.75;
30+
31+
/// Maximum interface scale (Telegram goes to 300%; 200% is enough for Querya).
32+
const double kMaxUiScale = 2.0;
33+
34+
/// Slider step — 1% increments when Shift is held (fine control).
35+
const double kUiScaleStep = 0.01;
36+
37+
/// Fixed tick marks on the interface scale slider (75% … 200%).
38+
const List<double> kUiScalePresets = [
39+
0.75,
40+
0.85,
41+
0.9,
42+
1.0,
43+
1.1,
44+
1.25,
45+
1.5,
46+
1.75,
47+
2.0,
48+
];
49+
50+
int nearestUiScalePresetIndex(double scale) {
51+
var best = 0;
52+
var bestDist = double.infinity;
53+
for (var i = 0; i < kUiScalePresets.length; i++) {
54+
final dist = (kUiScalePresets[i] - scale).abs();
55+
if (dist < bestDist) {
56+
bestDist = dist;
57+
best = i;
58+
}
59+
}
60+
return best;
61+
}
62+
63+
double snapUiScaleToPreset(double scale) =>
64+
kUiScalePresets[nearestUiScalePresetIndex(scale)];
65+
66+
double _normalizeUiScaleContinuous(double value) {
67+
final clamped = value.clamp(kMinUiScale, kMaxUiScale);
68+
final steps = ((clamped - kMinUiScale) / kUiScaleStep).round();
69+
return (kMinUiScale + steps * kUiScaleStep).clamp(kMinUiScale, kMaxUiScale);
70+
}
71+
72+
double _normalizeUiScale(double value, {bool fine = false}) =>
73+
fine ? _normalizeUiScaleContinuous(value) : snapUiScaleToPreset(value);
74+
2575
/// Default cap on stored SQL history entries per connection + database.
2676
const int kDefaultSqlHistoryMaxEntries = 100;
2777

@@ -57,6 +107,7 @@ abstract final class AppSettingsKeys {
57107
static const themeImportName = 'theme_import_name';
58108
static const themeImportedColorsJson = 'theme_imported_colors_json';
59109
static const themeAnimationEnabled = 'theme_animation_enabled';
110+
static const uiScale = 'ui_scale';
60111
}
61112

62113
/// Bumps [listenable] when any preference is persisted so open screens can reload.
@@ -159,6 +210,24 @@ class AppSettings {
159210
AppSettingsRevision.bump();
160211
}
161212

213+
/// Global interface scale for typography and compact controls.
214+
Future<double> getUiScale() async {
215+
final v = await LocalDb.instance.getAppSetting(AppSettingsKeys.uiScale);
216+
if (v == null || v.isEmpty) return kDefaultUiScale;
217+
final n = double.tryParse(v);
218+
if (n == null) return kDefaultUiScale;
219+
return _normalizeUiScaleContinuous(n);
220+
}
221+
222+
Future<void> setUiScale(double scale, {bool fine = false}) async {
223+
final normalized = _normalizeUiScale(scale, fine: fine);
224+
await LocalDb.instance.setAppSetting(
225+
AppSettingsKeys.uiScale,
226+
normalized.toStringAsFixed(2),
227+
);
228+
AppSettingsRevision.bump();
229+
}
230+
162231
/// Max SQL history rows kept per connection + database (oldest trimmed).
163232
Future<int> getSqlHistoryMaxEntries() async {
164233
final v = await LocalDb.instance.getAppSetting(

lib/features/connections/driver_manager_dialog.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ class _DriverManagerDialogContent extends material.StatelessWidget {
5151
final theme = Theme.of(context).colorScheme;
5252
final radius = Theme.of(context).radiusXxl;
5353
return material.Container(
54-
constraints: const material.BoxConstraints(maxWidth: 520, minWidth: 400),
54+
constraints: WindowLayout.dialogConstraints(
55+
context,
56+
maxWidth: 520,
57+
minWidth: 400,
58+
),
5559
decoration: material.BoxDecoration(
5660
color: theme.popover,
5761
borderRadius: material.BorderRadius.circular(radius),

lib/features/connections/new_connection_dialog.dart

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,8 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial
8989
material.Widget build(material.BuildContext context) {
9090
final theme = Theme.of(context).colorScheme;
9191
final radius = Theme.of(context).radiusXxl;
92-
final mq = MediaQuery.sizeOf(context);
93-
final dialogMaxW = WindowLayout.newConnectionDialogMaxWidth(mq.width);
94-
final dialogH = WindowLayout.newConnectionDialogHeight(mq.height);
92+
final dialogMaxW = WindowLayout.newConnectionDialogMaxWidth(context);
93+
final dialogH = WindowLayout.newConnectionDialogHeight(context);
9594
final headerPadH = dialogMaxW < 420 ? 16.0 : 24.0;
9695
final stackFilters = dialogMaxW < 520;
9796

@@ -185,8 +184,8 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial
185184
final innerW = math.max(0.0, constraints.maxWidth - gridPad * 2);
186185
final crossAxisCount =
187186
WindowLayout.dbTypeGridCrossAxisCount(innerW);
188-
final cardHeight =
189-
WindowLayout.dbTypeCardHeight(crossAxisCount);
187+
final cardHeight =
188+
WindowLayout.dbTypeCardHeight(context, crossAxisCount);
190189
final cardWidth = crossAxisCount > 0
191190
? (innerW - spacing * (crossAxisCount - 1)) / crossAxisCount
192191
: innerW;

lib/features/connections/new_folder_dialog.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ class _NewFolderDialogContentState extends material.State<_NewFolderDialogConten
3737
final theme = Theme.of(context).colorScheme;
3838
final radius = Theme.of(context).radiusXxl;
3939
return material.Container(
40-
constraints: const material.BoxConstraints(maxWidth: 440, minWidth: 360),
40+
constraints: WindowLayout.dialogConstraints(
41+
context,
42+
maxWidth: 440,
43+
minWidth: 360,
44+
),
4145
decoration: material.BoxDecoration(
4246
color: theme.popover,
4347
borderRadius: material.BorderRadius.circular(radius),

lib/features/main_screen/sql_query_history_dialog.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ class _SqlQueryHistoryDialogContentState
125125
final scheme = Theme.of(context).colorScheme;
126126
final radius = Theme.of(context).radiusXxl;
127127
return material.Container(
128-
constraints: const material.BoxConstraints(
128+
constraints: WindowLayout.dialogConstraints(
129+
context,
129130
maxWidth: 520,
130131
minWidth: 320,
131132
maxHeight: 440,

0 commit comments

Comments
 (0)