From 5ce32580063bbfbcb5581e433e898fd36b17ba72 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 10:23:12 +0300 Subject: [PATCH] feat(theme): ThemeController and runtime theme in QueryaApp Persist theme mode and preset in AppSettings, load on startup, and rebuild ShadcnApp from ThemeController via ListenableBuilder. Closes #40 Closes #41 --- lib/app/app.dart | 41 +++++----- lib/core/storage/app_settings.dart | 48 +++++++++++- lib/core/theme/parser/color_parser.dart | 2 +- lib/core/theme/querya_theme_preset.dart | 5 ++ lib/core/theme/theme_controller.dart | 76 ++++++++++++++++++ lib/main.dart | 2 + test/core/storage/app_settings_test.dart | 24 ++++++ test/core/theme/parser/color_parser_test.dart | 3 +- test/core/theme/theme_controller_test.dart | 78 +++++++++++++++++++ 9 files changed, 258 insertions(+), 21 deletions(-) create mode 100644 lib/core/theme/querya_theme_preset.dart create mode 100644 lib/core/theme/theme_controller.dart create mode 100644 test/core/theme/theme_controller_test.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 88b7b2c9..cf3769a6 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,6 +1,5 @@ -import 'package:querya_desktop/core/theme/app_theme.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/theme_controller.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'app_lifecycle_cleanup.dart'; @@ -11,22 +10,28 @@ class QueryaApp extends StatelessWidget { @override Widget build(BuildContext context) { - return ShadcnApp( - title: 'Querya', - theme: AppTheme.dark, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.dark, - debugShowCheckedModeBanner: false, - // Less churn in ShadcnAnimatedTheme; helps stability with overlay layers. - enableThemeAnimation: false, - // Avoids scroll interception fighting nested Scrollbars in data views. - enableScrollInterception: false, - home: const QueryaThemeScope( - data: QueryaTheme.darkDefault, - child: AppLifecycleCleanup( - child: MainScreen(), - ), - ), + final themeController = ThemeController.instance; + + return ListenableBuilder( + listenable: themeController, + builder: (context, _) { + final queryaTheme = themeController.activeTheme; + return ShadcnApp( + title: 'Querya', + theme: themeController.lightShadcnTheme, + darkTheme: themeController.darkShadcnTheme, + themeMode: themeController.themeMode, + debugShowCheckedModeBanner: false, + enableThemeAnimation: false, + enableScrollInterception: false, + home: QueryaThemeScope( + data: queryaTheme, + child: const AppLifecycleCleanup( + child: MainScreen(), + ), + ), + ); + }, ); } } diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 64023fce..8ff1c188 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -1,5 +1,6 @@ -import 'package:flutter/foundation.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import '../theme/querya_theme_preset.dart'; import 'local_db.dart'; /// Default cap on rows shown in SQL workspace result grids (full result may be larger). @@ -47,6 +48,8 @@ abstract final class AppSettingsKeys { static const sqlResultMaxRows = 'sql_result_max_rows'; static const sqlEditorFontSizePoints = 'sql_editor_font_size_points'; static const sqlHistoryMaxEntries = 'sql_history_max_entries'; + static const themeMode = 'theme_mode'; + static const themePreset = 'theme_preset'; } /// Bumps [listenable] when any preference is persisted so open screens can reload. @@ -170,4 +173,47 @@ class AppSettings { ); AppSettingsRevision.bump(); } + + /// UI theme mode (dark / light / system). + Future getThemeMode() async { + final v = await LocalDb.instance.getAppSetting(AppSettingsKeys.themeMode); + return switch (v) { + 'light' => ThemeMode.light, + 'system' => ThemeMode.system, + _ => ThemeMode.dark, + }; + } + + Future setThemeMode(ThemeMode mode) async { + final stored = switch (mode) { + ThemeMode.light => 'light', + ThemeMode.system => 'system', + ThemeMode.dark => 'dark', + }; + await LocalDb.instance.setAppSetting(AppSettingsKeys.themeMode, stored); + AppSettingsRevision.bump(); + } + + Future getThemePreset() async { + final v = await LocalDb.instance.getAppSetting(AppSettingsKeys.themePreset); + return switch (v) { + 'querya_light' => QueryaThemePreset.queryaLight, + _ => QueryaThemePreset.queryaDark, + }; + } + + Future setThemePreset(QueryaThemePreset preset) async { + final stored = switch (preset) { + QueryaThemePreset.queryaLight => 'querya_light', + QueryaThemePreset.queryaDark => 'querya_dark', + }; + await LocalDb.instance.setAppSetting(AppSettingsKeys.themePreset, stored); + AppSettingsRevision.bump(); + } + + Future clearThemeSettings() async { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeMode); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themePreset); + AppSettingsRevision.bump(); + } } diff --git a/lib/core/theme/parser/color_parser.dart b/lib/core/theme/parser/color_parser.dart index 065a1bc8..c4cae9f8 100644 --- a/lib/core/theme/parser/color_parser.dart +++ b/lib/core/theme/parser/color_parser.dart @@ -4,7 +4,7 @@ import 'dart:ui'; Color parseVsCodeColor(String input) { var s = input.trim(); if (s.isEmpty) { - throw FormatException('Empty color string'); + throw const FormatException('Empty color string'); } if (s.startsWith('#')) { s = s.substring(1); diff --git a/lib/core/theme/querya_theme_preset.dart b/lib/core/theme/querya_theme_preset.dart new file mode 100644 index 00000000..cb80b4c7 --- /dev/null +++ b/lib/core/theme/querya_theme_preset.dart @@ -0,0 +1,5 @@ +/// Built-in theme presets (imported VS Code themes — #44). +enum QueryaThemePreset { + queryaDark, + queryaLight, +} diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart new file mode 100644 index 00000000..fafd0eab --- /dev/null +++ b/lib/core/theme/theme_controller.dart @@ -0,0 +1,76 @@ +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import 'querya_theme.dart'; +import 'querya_theme_preset.dart'; + +/// Active theme state: preset + [ThemeMode], persisted via [AppSettings]. +class ThemeController extends ChangeNotifier { + ThemeController._(); + + static final ThemeController instance = ThemeController._(); + + ThemeMode _themeMode = ThemeMode.dark; + QueryaThemePreset _preset = QueryaThemePreset.queryaDark; + bool _loaded = false; + + ThemeMode get themeMode => _themeMode; + + QueryaThemePreset get preset => _preset; + + bool get isLoaded => _loaded; + + /// Workbench + editor tokens for the current preset/mode. + QueryaTheme get activeTheme { + if (_themeMode == ThemeMode.system) { + final b = WidgetsBinding.instance.platformDispatcher.platformBrightness; + return b == Brightness.dark + ? QueryaTheme.darkDefault + : QueryaTheme.lightDefault; + } + return _preset == QueryaThemePreset.queryaLight + ? QueryaTheme.lightDefault + : QueryaTheme.darkDefault; + } + + ThemeData get lightShadcnTheme => + QueryaTheme.lightDefault.toShadcnThemeData(); + + ThemeData get darkShadcnTheme => QueryaTheme.darkDefault.toShadcnThemeData(); + + Future load() async { + final mode = await AppSettings.instance.getThemeMode(); + final preset = await AppSettings.instance.getThemePreset(); + _themeMode = mode; + _preset = preset; + _loaded = true; + notifyListeners(); + } + + Future setThemeMode(ThemeMode mode) async { + _themeMode = mode; + _preset = mode == ThemeMode.light + ? QueryaThemePreset.queryaLight + : QueryaThemePreset.queryaDark; + await AppSettings.instance.setThemeMode(mode); + await AppSettings.instance.setThemePreset(_preset); + notifyListeners(); + } + + Future setPreset(QueryaThemePreset preset) async { + _preset = preset; + _themeMode = preset == QueryaThemePreset.queryaLight + ? ThemeMode.light + : ThemeMode.dark; + await AppSettings.instance.setThemePreset(preset); + await AppSettings.instance.setThemeMode(_themeMode); + notifyListeners(); + } + + Future resetToDefaults() async { + await AppSettings.instance.clearThemeSettings(); + _themeMode = ThemeMode.dark; + _preset = QueryaThemePreset.queryaDark; + notifyListeners(); + } +} diff --git a/lib/main.dart b/lib/main.dart index cceb337c..d6436b4c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,10 +3,12 @@ import 'package:flutter/material.dart'; import 'app/app.dart'; import 'core/storage/local_db.dart'; +import 'core/theme/theme_controller.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await LocalDb.initFfi(); + await ThemeController.instance.load(); runApp(const QueryaApp()); doWhenWindowReady(() { final win = appWindow; diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 7a50557d..5770cb7a 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -4,6 +4,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_theme_preset.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; /// path_provider has no implementation in plain `flutter test`; LocalDb needs a path. class _FakePathProvider extends PathProviderPlatform { @@ -63,6 +65,7 @@ void main() { await LocalDb.instance.deleteAppSetting(AppSettingsKeys.sqlResultMaxRows); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.sqlEditorFontSizePoints); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.sqlHistoryMaxEntries); + await AppSettings.instance.clearThemeSettings(); }); group('AppSettings', () { @@ -178,6 +181,27 @@ void main() { }); }); + group('theme settings', () { + test('theme mode and preset roundtrip', () async { + expect(await AppSettings.instance.getThemeMode(), ThemeMode.dark); + expect( + await AppSettings.instance.getThemePreset(), + QueryaThemePreset.queryaDark, + ); + + await AppSettings.instance.setThemeMode(ThemeMode.light); + await AppSettings.instance.setThemePreset(QueryaThemePreset.queryaLight); + expect(await AppSettings.instance.getThemeMode(), ThemeMode.light); + expect( + await AppSettings.instance.getThemePreset(), + QueryaThemePreset.queryaLight, + ); + + await AppSettings.instance.clearThemeSettings(); + expect(await AppSettings.instance.getThemeMode(), ThemeMode.dark); + }); + }); + group('AppSettingsRevision', () { test('bump increments listenable value', () { final start = AppSettingsRevision.listenable.value; diff --git a/test/core/theme/parser/color_parser_test.dart b/test/core/theme/parser/color_parser_test.dart index 78c4fdd9..014598de 100644 --- a/test/core/theme/parser/color_parser_test.dart +++ b/test/core/theme/parser/color_parser_test.dart @@ -10,7 +10,8 @@ void main() { }); test('8-digit RRGGBBAA', () { - expect(parseVsCodeColor('#11223344').alpha, 0x44); + final c = parseVsCodeColor('#11223344'); + expect((c.a * 255).round(), 0x44); }); test('3-digit shorthand', () { diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart new file mode 100644 index 00000000..3da820c4 --- /dev/null +++ b/test/core/theme/theme_controller_test.dart @@ -0,0 +1,78 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_preset.dart'; +import 'package:querya_desktop/core/theme/theme_controller.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = + await Directory.systemTemp.createTemp('querya_theme_controller_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + tearDown(() async { + await AppSettings.instance.clearThemeSettings(); + await ThemeController.instance.load(); + }); + + test('load defaults to dark preset', () async { + final c = ThemeController.instance; + await c.load(); + expect(c.themeMode, ThemeMode.dark); + expect(c.preset, QueryaThemePreset.queryaDark); + expect(c.activeTheme, QueryaTheme.darkDefault); + expect(c.isLoaded, isTrue); + }); + + test('setThemeMode light persists and updates activeTheme', () async { + final c = ThemeController.instance; + await c.load(); + await c.setThemeMode(ThemeMode.light); + expect(c.activeTheme, QueryaTheme.lightDefault); + expect(await AppSettings.instance.getThemeMode(), ThemeMode.light); + expect( + await AppSettings.instance.getThemePreset(), + QueryaThemePreset.queryaLight, + ); + }); + + test('resetToDefaults restores dark', () async { + final c = ThemeController.instance; + await c.setThemeMode(ThemeMode.light); + await c.resetToDefaults(); + expect(c.themeMode, ThemeMode.dark); + expect(c.activeTheme, QueryaTheme.darkDefault); + }); +}