From 3000afaaacba37a579bb91812c05207486824c04 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 17:27:50 +0300 Subject: [PATCH] refactor(theme): QueryaUiColors and context.colors as the app's colour type, bridged to the shadcn scheme (#1730) --- lib/core/theme/querya_color_scheme.dart | 67 +++++++++ lib/core/theme/querya_theme.dart | 9 +- lib/core/theme/querya_theme_scope.dart | 23 ++- lib/core/theme/querya_ui_colors.dart | 131 ++++++++++++++++++ .../connections/driver_manager_dialog.dart | 3 +- .../connections/new_connection_dialog.dart | 3 +- lib/shared/widgets/querya_dropdown.dart | 16 +-- test/core/theme/querya_ui_colors_test.dart | 123 ++++++++++++++++ 8 files changed, 356 insertions(+), 19 deletions(-) create mode 100644 lib/core/theme/querya_ui_colors.dart create mode 100644 test/core/theme/querya_ui_colors_test.dart diff --git a/lib/core/theme/querya_color_scheme.dart b/lib/core/theme/querya_color_scheme.dart index 7b87a0b6..4f705efe 100644 --- a/lib/core/theme/querya_color_scheme.dart +++ b/lib/core/theme/querya_color_scheme.dart @@ -1,9 +1,76 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; import 'querya_theme.dart'; +import 'querya_ui_colors.dart'; /// Shadcn [ColorScheme] presets — derived from [QueryaTheme] defaults. abstract class QueryaColorScheme { static ColorScheme get dark => QueryaTheme.darkDefault.colorScheme; static ColorScheme get light => QueryaTheme.lightDefault.colorScheme; } + +final Expando _uiColorsCache = Expando(); + +/// The bridge between shadcn's scheme and [QueryaUiColors] while the app still +/// runs on shadcn's shell (#1730, epic #1465); it goes with #1735. +extension QueryaUiColorsFromShadcn on ColorScheme { + /// The same colours as [QueryaUiColors] (cached per scheme instance). + QueryaUiColors get uiColors => _uiColorsCache[this] ??= QueryaUiColors( + brightness: brightness, + background: background, + foreground: foreground, + card: card, + popover: popover, + popoverForeground: popoverForeground, + primary: primary, + primaryForeground: primaryForeground, + secondary: secondary, + secondaryForeground: secondaryForeground, + muted: muted, + mutedForeground: mutedForeground, + accent: accent, + destructive: destructive, + destructiveForeground: destructiveForeground, + border: border, + ring: ring, + chart1: chart1, + chart2: chart2, + chart3: chart3, + chart4: chart4, + chart5: chart5, + ); +} + +extension QueryaUiColorsToShadcn on QueryaUiColors { + /// A shadcn scheme for the widgets still on shadcn. The fields shadcn has + /// and the app does not read follow the ones next to them, as every Querya + /// theme sets them today: card and accent text are [foreground], the input + /// outline is [border]. + ColorScheme toShadcn() => ColorScheme( + brightness: brightness, + background: background, + foreground: foreground, + card: card, + cardForeground: foreground, + popover: popover, + popoverForeground: popoverForeground, + primary: primary, + primaryForeground: primaryForeground, + secondary: secondary, + secondaryForeground: secondaryForeground, + muted: muted, + mutedForeground: mutedForeground, + accent: accent, + accentForeground: foreground, + destructive: destructive, + destructiveForeground: destructiveForeground, + border: border, + input: border, + ring: ring, + chart1: chart1, + chart2: chart2, + chart3: chart3, + chart4: chart4, + chart5: chart5, + ); +} diff --git a/lib/core/theme/querya_theme.dart b/lib/core/theme/querya_theme.dart index 33c4cfca..6f99d88b 100644 --- a/lib/core/theme/querya_theme.dart +++ b/lib/core/theme/querya_theme.dart @@ -2,14 +2,12 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' hide Gap, TextExtension; import 'color_contrast.dart'; import 'parser/vscode_theme_manifest.dart'; +import 'querya_color_scheme.dart'; import 'querya_colors.dart'; +import 'querya_ui_colors.dart'; import 'querya_editor_theme.dart'; import 'querya_workbench_theme.dart'; -/// The type of [QueryaTheme.colorScheme], nameable by widgets that must not -/// import the UI library behind it. -typedef QueryaThemeColorScheme = ColorScheme; - /// Full Querya theme: workbench chrome + editor tokens + shadcn [ColorScheme]. class QueryaTheme { const QueryaTheme({ @@ -25,6 +23,9 @@ class QueryaTheme { final Brightness brightness; final ColorScheme colorScheme; + /// [colorScheme] as the app's own colour type (#1730). + QueryaUiColors get colors => colorScheme.uiColors; + /// VS Code `tokenColors` for syntax highlighting (imported themes). final List tokenColors; diff --git a/lib/core/theme/querya_theme_scope.dart b/lib/core/theme/querya_theme_scope.dart index 4b3ed85e..87b258cd 100644 --- a/lib/core/theme/querya_theme_scope.dart +++ b/lib/core/theme/querya_theme_scope.dart @@ -3,9 +3,11 @@ import 'package:flutter/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn hide TextExtension; +import 'querya_color_scheme.dart'; import 'querya_editor_theme.dart'; import 'querya_semantic_palette.dart'; import 'querya_theme.dart'; +import 'querya_ui_colors.dart'; import 'querya_workbench_theme.dart'; /// Provides [QueryaTheme] (workbench + editor tokens) below [ShadcnApp]. @@ -54,15 +56,26 @@ extension QueryaThemeContext on BuildContext { /// Shortcut for Material [material.Theme.of]. material.ThemeData get theme => material.Theme.of(this); - /// Shortcut for [shadcn.ColorScheme] from [shadcn.Theme.of]. - shadcn.ColorScheme get colors => shadcn.Theme.of(this).colorScheme; + /// The colours widgets read (#1730): the ones the running shell uses, so a + /// caller that moves here from `Theme.of(context).colorScheme` sees the same + /// values, mid theme animation included. Without a shell theme (a bare app + /// in a test) the nearest [QueryaThemeScope]'s, else the default theme's. + QueryaUiColors get colors { + final shell = + dependOnInheritedWidgetOfExactType()?.data.colorScheme; + if (shell != null) return shell.uiColors; + return (QueryaThemeScope.maybeOf(this) ?? + (_ambientBrightness == Brightness.light + ? QueryaTheme.lightDefault + : QueryaTheme.darkDefault)) + .colors; + } QueryaTheme get queryaTheme => QueryaThemeScope.of(this); /// The brightness the defaults below follow when there is no - /// [QueryaThemeScope]: the shadcn theme's, else Material's. [colors] asserts - /// without a shadcn theme, and these defaults exist for exactly that case - /// (a widget under a bare app, as in tests). + /// [QueryaThemeScope]: the shadcn theme's, else Material's (a widget under a + /// bare app, as in tests). Brightness get _ambientBrightness => dependOnInheritedWidgetOfExactType() ?.data diff --git a/lib/core/theme/querya_ui_colors.dart b/lib/core/theme/querya_ui_colors.dart new file mode 100644 index 00000000..92ad1f54 --- /dev/null +++ b/lib/core/theme/querya_ui_colors.dart @@ -0,0 +1,131 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +/// The colours the app's widgets read: surfaces, text, accent, borders and +/// the chart series (#1730, epic #1465). +/// +/// Holds exactly the fields `lib/` uses of shadcn's `ColorScheme`, under the +/// same names, so a caller moves from `Theme.of(context).colorScheme` to +/// `context.colors` without other changes. While the app still runs on +/// shadcn's shell the values are read from the shadcn scheme and converted in +/// `querya_color_scheme.dart`, the one place that knows both; after #1735 this +/// is the source. +@immutable +class QueryaUiColors { + const QueryaUiColors({ + required this.brightness, + required this.background, + required this.foreground, + required this.card, + required this.popover, + required this.popoverForeground, + required this.primary, + required this.primaryForeground, + required this.secondary, + required this.secondaryForeground, + required this.muted, + required this.mutedForeground, + required this.accent, + required this.destructive, + required this.destructiveForeground, + required this.border, + required this.ring, + required this.chart1, + required this.chart2, + required this.chart3, + required this.chart4, + required this.chart5, + }); + + final Brightness brightness; + + /// Page background. + final Color background; + + /// Main text and icons. + final Color foreground; + + /// Raised surfaces: cards, panels. + final Color card; + + /// Menus, dropdowns and other floating surfaces, and their text. + final Color popover; + final Color popoverForeground; + + /// The accent: filled buttons, selection, links; and what sits on it. + final Color primary; + final Color primaryForeground; + + /// Quiet filled surfaces (secondary buttons) and their text. + final Color secondary; + final Color secondaryForeground; + + /// Subdued backgrounds (hover, chips) and secondary text. + final Color muted; + final Color mutedForeground; + + /// Hover and highlight background. + final Color accent; + + /// Errors and destructive actions, and what sits on them. + final Color destructive; + final Color destructiveForeground; + + /// Hairlines and control outlines; the focus ring. + final Color border; + final Color ring; + + /// Chart series. + final Color chart1; + final Color chart2; + final Color chart3; + final Color chart4; + final Color chart5; + + static QueryaUiColors lerp(QueryaUiColors a, QueryaUiColors b, double t) { + Color l(Color x, Color y) => Color.lerp(x, y, t)!; + return QueryaUiColors( + brightness: t < 0.5 ? a.brightness : b.brightness, + background: l(a.background, b.background), + foreground: l(a.foreground, b.foreground), + card: l(a.card, b.card), + popover: l(a.popover, b.popover), + popoverForeground: l(a.popoverForeground, b.popoverForeground), + primary: l(a.primary, b.primary), + primaryForeground: l(a.primaryForeground, b.primaryForeground), + secondary: l(a.secondary, b.secondary), + secondaryForeground: l(a.secondaryForeground, b.secondaryForeground), + muted: l(a.muted, b.muted), + mutedForeground: l(a.mutedForeground, b.mutedForeground), + accent: l(a.accent, b.accent), + destructive: l(a.destructive, b.destructive), + destructiveForeground: + l(a.destructiveForeground, b.destructiveForeground), + border: l(a.border, b.border), + ring: l(a.ring, b.ring), + chart1: l(a.chart1, b.chart1), + chart2: l(a.chart2, b.chart2), + chart3: l(a.chart3, b.chart3), + chart4: l(a.chart4, b.chart4), + chart5: l(a.chart5, b.chart5), + ); + } + + List get _props => [ + brightness, background, foreground, card, popover, popoverForeground, + primary, primaryForeground, secondary, secondaryForeground, muted, + mutedForeground, accent, destructive, destructiveForeground, border, + ring, chart1, chart2, chart3, chart4, chart5, + ]; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! QueryaUiColors) return false; + return listEquals(_props, other._props); + } + + @override + int get hashCode => Object.hashAll(_props); +} diff --git a/lib/features/connections/driver_manager_dialog.dart b/lib/features/connections/driver_manager_dialog.dart index 4cdba0e8..c777f080 100644 --- a/lib/features/connections/driver_manager_dialog.dart +++ b/lib/features/connections/driver_manager_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; @@ -147,7 +148,7 @@ class _DriverRow extends material.StatelessWidget { }); final _DriverInfo info; - final ColorScheme theme; + final QueryaUiColors theme; @override material.Widget build(material.BuildContext context) { diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index 567ec748..de82c4bf 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/database/connection_type_choice.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; @@ -357,7 +358,7 @@ class _DbTypeCard extends material.StatelessWidget { }); final ConnectionTypeChoice choice; - final ColorScheme theme; + final QueryaUiColors theme; final bool selected; final VoidCallback onTap; diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index b917d6d2..476ff60d 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -6,8 +6,8 @@ import 'package:flutter/services.dart'; import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; -import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; @@ -30,7 +30,7 @@ class QueryaDropdownItem { /// Stable dropdown built on [material.MenuAnchor] (no Overlay portal). /// -/// Visual metrics: [QueryaDropdownTokens]. Colors from shadcn [QueryaThemeColorScheme]. +/// Visual metrics: [QueryaDropdownTokens]. Colors from [QueryaUiColors]. /// /// **Exit motion:** item pick and trigger-toggle delay [MenuController.close] /// so fade-slide can run. Outside-tap / focus-loss closes via [MenuAnchor] @@ -153,7 +153,7 @@ class _QueryaDropdownState extends material.State> { return QueryaControlScope.maybeOf(context) ?? QueryaControlSize.lg; } - List _menuChildren(QueryaThemeColorScheme cs, QueryaControlSize size) { + List _menuChildren(QueryaUiColors cs, QueryaControlSize size) { if (_cachedMenuChildren != null && listEquals(_cachedMenuItems, widget.items) && _cachedMenuValue == widget.value && @@ -172,7 +172,7 @@ class _QueryaDropdownState extends material.State> { material.Widget _triggerLabelText({ required material.BuildContext context, required String label, - required QueryaThemeColorScheme cs, + required QueryaUiColors cs, required bool expand, required QueryaControlSize size, }) { @@ -204,7 +204,7 @@ class _QueryaDropdownState extends material.State> { material.Widget _menuItem( QueryaDropdownItem item, - QueryaThemeColorScheme cs, + QueryaUiColors cs, QueryaControlSize size, ) { return _QueryaDropdownMenuItem( @@ -224,7 +224,7 @@ class _QueryaDropdownState extends material.State> { material.Widget _buildTrigger({ required material.BuildContext context, required material.MenuController controller, - required QueryaThemeColorScheme cs, + required QueryaUiColors cs, required String label, required double? fieldWidth, }) { @@ -512,7 +512,7 @@ class _QueryaDropdownMenuItem extends material.StatefulWidget { final QueryaDropdownItem item; final bool selected; final bool enabled; - final QueryaThemeColorScheme colorScheme; + final QueryaUiColors colorScheme; final bool compact; final QueryaControlSize size; final material.VoidCallback onPick; @@ -527,7 +527,7 @@ class _QueryaDropdownMenuItemState bool _hovered = false; bool _focused = false; - material.Widget _leading(material.BuildContext context, QueryaThemeColorScheme cs) { + material.Widget _leading(material.BuildContext context, QueryaUiColors cs) { final slot = context.scaled(QueryaDropdownTokens.selectedCheckSlotWidth); final checkSize = context.scaled(QueryaDropdownTokens.selectedCheckSize); if (widget.selected) { diff --git a/test/core/theme/querya_ui_colors_test.dart b/test/core/theme/querya_ui_colors_test.dart new file mode 100644 index 00000000..e7bf84ed --- /dev/null +++ b/test/core/theme/querya_ui_colors_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/parser/querya_theme_from_vscode.dart'; +import 'package:querya_desktop/core/theme/querya_color_scheme.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/theme/querya_ui_colors.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn + hide Gap, TextExtension; + +import '../../support/querya_theme_test_shell.dart'; + +/// QueryaUiColors gives the colours the shadcn scheme gives today (#1730). +void main() { + final imported = buildQueryaThemeFromVsCodeColors( + brightness: shadcn.Brightness.dark, + colors: const { + 'editor.background': '#1e1e2e', + 'focusBorder': '#f38ba8', + 'errorForeground': '#ff5555', + }, + fallback: QueryaTheme.darkDefault, + ); + final themes = { + 'dark': QueryaTheme.darkDefault, + 'light': QueryaTheme.lightDefault, + 'imported': imported, + }; + + for (final MapEntry(key: name, value: theme) in themes.entries) { + test('$name: every field equals the shadcn scheme field', () { + final s = theme.colorScheme; + final c = theme.colors; + expect(c.brightness, s.brightness); + expect(c.background, s.background); + expect(c.foreground, s.foreground); + expect(c.card, s.card); + expect(c.popover, s.popover); + expect(c.popoverForeground, s.popoverForeground); + expect(c.primary, s.primary); + expect(c.primaryForeground, s.primaryForeground); + expect(c.secondary, s.secondary); + expect(c.secondaryForeground, s.secondaryForeground); + expect(c.muted, s.muted); + expect(c.mutedForeground, s.mutedForeground); + expect(c.accent, s.accent); + expect(c.destructive, s.destructive); + expect(c.destructiveForeground, s.destructiveForeground); + expect(c.border, s.border); + expect(c.ring, s.ring); + expect([c.chart1, c.chart2, c.chart3, c.chart4, c.chart5], + [s.chart1, s.chart2, s.chart3, s.chart4, s.chart5]); + }); + + test('$name: back to a shadcn scheme it is the same scheme', () { + final s = theme.colorScheme; + final back = theme.colors.toShadcn(); + expect(back.uiColors, theme.colors); + // The fields the app does not read follow their neighbours, as every + // Querya theme sets them. + expect(back.cardForeground, s.cardForeground); + expect(back.accentForeground, s.accentForeground); + expect(back.input, s.input); + }); + } + + test('one scheme instance gives one QueryaUiColors', () { + final s = QueryaTheme.darkDefault.colorScheme; + expect(identical(s.uiColors, s.uiColors), isTrue); + }); + + test('lerp: the ends are the themes, the middle is between', () { + final a = QueryaTheme.darkDefault.colors; + final b = QueryaTheme.lightDefault.colors; + expect(QueryaUiColors.lerp(a, b, 0), a); + expect(QueryaUiColors.lerp(a, b, 1), b); + final mid = QueryaUiColors.lerp(a, b, 0.5); + expect(mid.background, material.Color.lerp(a.background, b.background, 0.5)); + expect(mid.brightness, b.brightness); + }); + + group('context.colors', () { + testWidgets('under the shell: the shell theme\'s colours', (tester) async { + late QueryaUiColors seen; + await tester.pumpWidget(queryaThemeTestShell( + data: QueryaTheme.lightDefault, + child: material.Builder(builder: (context) { + seen = context.colors; + return const material.SizedBox.shrink(); + }), + )); + expect(seen, QueryaTheme.lightDefault.colors); + }); + + testWidgets('a bare app: the default theme of its brightness', + (tester) async { + late QueryaUiColors seen; + await tester.pumpWidget(material.MaterialApp( + theme: material.ThemeData(brightness: material.Brightness.light), + home: material.Builder(builder: (context) { + seen = context.colors; + return const material.SizedBox.shrink(); + }), + )); + expect(seen, QueryaTheme.lightDefault.colors); + }); + + testWidgets('a scope without the shell: the scope\'s colours', + (tester) async { + late QueryaUiColors seen; + await tester.pumpWidget(material.MaterialApp( + home: QueryaThemeScope( + data: imported, + child: material.Builder(builder: (context) { + seen = context.colors; + return const material.SizedBox.shrink(); + }), + ), + )); + expect(seen, imported.colors); + }); + }); +}