From e46770f056410a3d671f9d99945d3217bb795aeb Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 28 May 2026 11:50:50 +0300 Subject: [PATCH] feat(theme): optional animated theme transitions (#57) Add AppSettings flag (default off), Preferences toggle, and wire ShadcnApp enableThemeAnimation. Document manual QA checklist; align ThemeData.lerp tests. --- docs/roadmap.md | 2 +- docs/theme.md | 15 ++++++++++- lib/app/app.dart | 2 +- lib/core/storage/app_settings.dart | 25 +++++++++++++++++++ lib/core/theme/theme_controller.dart | 13 ++++++++++ .../preferences_appearance_section.dart | 19 ++++++++++++++ test/core/storage/app_settings_test.dart | 14 +++++++++++ test/core/theme/querya_theme_test.dart | 13 ++++++++++ test/core/theme/theme_controller_test.dart | 14 +++++++++++ 9 files changed, 114 insertions(+), 3 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 1f2a397..e3081b5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -5,7 +5,7 @@ Living document for planned work. Not a commitment order; adjust as priorities c ## Theme system - **Done:** runtime themes, VS Code `colors` import, `tokenColors` syntax highlighting — see [theme.md](theme.md). -- **Later:** animated theme transitions ([#57](https://github.com/QueryaHub/Querya-Desktop/issues/57)), advanced editor (LSP / `code_forge` spike). +- **Later:** advanced editor (LSP / `code_forge` spike). Theme transitions: Preferences → **Animate theme changes** (off by default). ## Query history and favorites diff --git a/docs/theme.md b/docs/theme.md index 660386f..1765fc0 100644 --- a/docs/theme.md +++ b/docs/theme.md @@ -146,6 +146,19 @@ See also: [theme-import.md](theme-import.md). Run: `flutter test test/core/theme/` +## Theme transition animation + +Off by default. Enable in **Preferences → Appearance → Animate theme changes** to +turn on `ShadcnApp.enableThemeAnimation`. + +Manual QA (with animation enabled): + +- [ ] Toggle dark / light / system — no stuck overlay or wrong brightness on dialogs +- [ ] Switch preset (Querya Dark ↔ Light, imported) — sidebars and editor chrome animate smoothly +- [ ] Open connection dialog, settings sheet, SQL history — backgrounds readable during transition +- [ ] Resize main window while toggling theme — no layout jump or transparent holes +- [ ] Import theme while animation on — editor and workbench settle to final colors + ## Roadmap (Phase 2+) | Topic | Status | @@ -154,7 +167,7 @@ Run: `flutter test test/core/theme/` | Preferences UI | Done | | SQL/JSON syntax highlighting | Done | | `tokenColors` → highlighter | Done | -| Theme transition animation | [#57](https://github.com/QueryaHub/Querya-Desktop/issues/57) | +| Theme transition animation | Preferences → **Animate theme changes** (default off) | | `code_forge` / LSP editor | [#52](https://github.com/QueryaHub/Querya-Desktop/issues/52) | ## Related docs diff --git a/lib/app/app.dart b/lib/app/app.dart index cf3769a..95220c9 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -22,7 +22,7 @@ class QueryaApp extends StatelessWidget { darkTheme: themeController.darkShadcnTheme, themeMode: themeController.themeMode, debugShowCheckedModeBanner: false, - enableThemeAnimation: false, + enableThemeAnimation: themeController.themeAnimationEnabled, enableScrollInterception: false, home: QueryaThemeScope( data: queryaTheme, diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 85b13b3..77c537b 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -56,6 +56,7 @@ abstract final class AppSettingsKeys { static const themeImportPath = 'theme_import_path'; static const themeImportName = 'theme_import_name'; static const themeImportedColorsJson = 'theme_imported_colors_json'; + static const themeAnimationEnabled = 'theme_animation_enabled'; } /// Bumps [listenable] when any preference is persisted so open screens can reload. @@ -349,10 +350,34 @@ class AppSettings { AppSettingsRevision.bump(); } + /// Smooth color transitions when switching theme (off by default). + Future getThemeAnimationEnabled() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.themeAnimationEnabled, + ); + if (v == null || v.isEmpty) return false; + return v == 'true' || v == '1'; + } + + Future setThemeAnimationEnabled(bool enabled) async { + if (!enabled) { + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.themeAnimationEnabled, + ); + } else { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.themeAnimationEnabled, + 'true', + ); + } + AppSettingsRevision.bump(); + } + Future clearThemeSettings() async { await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeMode); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themePreset); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeOverridesJson); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeAnimationEnabled); await deleteThemeImportKeys(); AppSettingsRevision.bump(); } diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 70eeec0..ac56209 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -23,9 +23,13 @@ class ThemeController extends ChangeNotifier { Map _userOverrides = const {}; String? _importedThemeName; bool _loaded = false; + bool _themeAnimationEnabled = false; ThemeMode get themeMode => _themeMode; + /// When true, [QueryaApp] enables ShadcnAnimatedTheme transitions. + bool get themeAnimationEnabled => _themeAnimationEnabled; + QueryaThemePreset get preset => _preset; bool get isLoaded => _loaded; @@ -99,10 +103,18 @@ class ThemeController extends ChangeNotifier { _preset = preset; _userOverrides = Map.unmodifiable(overrides); _importedColors = Map.unmodifiable(imported); + _themeAnimationEnabled = + await AppSettings.instance.getThemeAnimationEnabled(); _loaded = true; notifyListeners(); } + Future setThemeAnimationEnabled(bool enabled) async { + _themeAnimationEnabled = enabled; + await AppSettings.instance.setThemeAnimationEnabled(enabled); + notifyListeners(); + } + Future setThemeMode(ThemeMode mode) async { _themeMode = mode; if (_preset != QueryaThemePreset.imported) { @@ -205,6 +217,7 @@ class ThemeController extends ChangeNotifier { _importedTokenColors = const []; _userOverrides = const {}; _importedThemeName = null; + _themeAnimationEnabled = false; notifyListeners(); } diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 21280fd..2c520d0 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -84,6 +84,10 @@ class _PreferencesAppearanceSectionState if (mounted) setState(() => _importError = null); } + Future _setThemeAnimation(bool enabled) async { + await _controller.setThemeAnimationEnabled(enabled); + } + @override material.Widget build(material.BuildContext context) { final c = _controller; @@ -158,6 +162,21 @@ class _PreferencesAppearanceSectionState ], ), const material.SizedBox(height: 12), + material.Row( + children: [ + const Text('Animate theme changes').small(), + const material.SizedBox(width: 12), + material.Switch( + value: c.themeAnimationEnabled, + onChanged: (v) => unawaited(_setThemeAnimation(v)), + ), + ], + ), + const material.SizedBox(height: 4), + const Text( + 'Smooth transitions when switching dark/light or presets. Off by default for stability.', + ).muted().xSmall(), + const material.SizedBox(height: 12), material.Wrap( spacing: 8, runSpacing: 8, diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 1b8d60a..5b1a0d9 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -201,6 +201,20 @@ void main() { expect(await AppSettings.instance.getThemeMode(), ThemeMode.dark); }); + test('theme animation defaults off and roundtrip', () async { + expect(await AppSettings.instance.getThemeAnimationEnabled(), isFalse); + + await AppSettings.instance.setThemeAnimationEnabled(true); + expect(await AppSettings.instance.getThemeAnimationEnabled(), isTrue); + + await AppSettings.instance.setThemeAnimationEnabled(false); + expect(await AppSettings.instance.getThemeAnimationEnabled(), isFalse); + + await AppSettings.instance.setThemeAnimationEnabled(true); + await AppSettings.instance.clearThemeSettings(); + expect(await AppSettings.instance.getThemeAnimationEnabled(), isFalse); + }); + test('theme color overrides json roundtrip', () async { await AppSettings.instance.setThemeColorOverrides({ 'sideBar.background': '#ff0000', diff --git a/test/core/theme/querya_theme_test.dart b/test/core/theme/querya_theme_test.dart index 6e28b71..6541b0f 100644 --- a/test/core/theme/querya_theme_test.dart +++ b/test/core/theme/querya_theme_test.dart @@ -88,5 +88,18 @@ void main() { expect(td.brightness, Brightness.dark); expect(td.colorScheme.primary, QueryaColors.accentCyan); }); + + test('ThemeData.lerp at 0.5 matches QueryaTheme.lerp colorScheme', () { + const a = QueryaTheme.darkDefault; + const b = QueryaTheme.lightDefault; + final qaMid = QueryaTheme.lerp(a, b, 0.5); + final tdMid = ThemeData.lerp( + a.toShadcnThemeData(), + b.toShadcnThemeData(), + 0.5, + ); + expect(tdMid.colorScheme.primary, qaMid.colorScheme.primary); + expect(tdMid.colorScheme.background, qaMid.colorScheme.background); + }); }); } diff --git a/test/core/theme/theme_controller_test.dart b/test/core/theme/theme_controller_test.dart index c1b87cd..ece87f8 100644 --- a/test/core/theme/theme_controller_test.dart +++ b/test/core/theme/theme_controller_test.dart @@ -117,6 +117,20 @@ void main() { expect(c.preset, QueryaThemePreset.queryaDark); }); + test('setThemeAnimationEnabled persists and reset clears', () async { + final c = ThemeController.instance; + await c.load(); + expect(c.themeAnimationEnabled, isFalse); + + await c.setThemeAnimationEnabled(true); + expect(c.themeAnimationEnabled, isTrue); + expect(await AppSettings.instance.getThemeAnimationEnabled(), isTrue); + + await c.resetToDefaults(); + expect(c.themeAnimationEnabled, isFalse); + expect(await AppSettings.instance.getThemeAnimationEnabled(), isFalse); + }); + test('clearColorOverrides does not reset theme mode', () async { final c = ThemeController.instance; await c.setThemeMode(ThemeMode.light);