Skip to content

Commit a05abeb

Browse files
Merge pull request #82 from QueryaHub/issue/57-theme-animation
feat(theme): optional animated theme transitions
2 parents 8e73fcb + e46770f commit a05abeb

9 files changed

Lines changed: 114 additions & 3 deletions

File tree

docs/roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Living document for planned work. Not a commitment order; adjust as priorities c
55
## Theme system
66

77
- **Done:** runtime themes, VS Code `colors` import, `tokenColors` syntax highlighting — see [theme.md](theme.md).
8-
- **Later:** animated theme transitions ([#57](https://github.com/QueryaHub/Querya-Desktop/issues/57)), advanced editor (LSP / `code_forge` spike).
8+
- **Later:** advanced editor (LSP / `code_forge` spike). Theme transitions: Preferences → **Animate theme changes** (off by default).
99

1010
## Query history and favorites
1111

docs/theme.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,19 @@ See also: [theme-import.md](theme-import.md).
146146

147147
Run: `flutter test test/core/theme/`
148148

149+
## Theme transition animation
150+
151+
Off by default. Enable in **Preferences → Appearance → Animate theme changes** to
152+
turn on `ShadcnApp.enableThemeAnimation`.
153+
154+
Manual QA (with animation enabled):
155+
156+
- [ ] Toggle dark / light / system — no stuck overlay or wrong brightness on dialogs
157+
- [ ] Switch preset (Querya Dark ↔ Light, imported) — sidebars and editor chrome animate smoothly
158+
- [ ] Open connection dialog, settings sheet, SQL history — backgrounds readable during transition
159+
- [ ] Resize main window while toggling theme — no layout jump or transparent holes
160+
- [ ] Import theme while animation on — editor and workbench settle to final colors
161+
149162
## Roadmap (Phase 2+)
150163

151164
| Topic | Status |
@@ -154,7 +167,7 @@ Run: `flutter test test/core/theme/`
154167
| Preferences UI | Done |
155168
| SQL/JSON syntax highlighting | Done |
156169
| `tokenColors` → highlighter | Done |
157-
| Theme transition animation | [#57](https://github.com/QueryaHub/Querya-Desktop/issues/57) |
170+
| Theme transition animation | Preferences → **Animate theme changes** (default off) |
158171
| `code_forge` / LSP editor | [#52](https://github.com/QueryaHub/Querya-Desktop/issues/52) |
159172

160173
## Related docs

lib/app/app.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class QueryaApp extends StatelessWidget {
2222
darkTheme: themeController.darkShadcnTheme,
2323
themeMode: themeController.themeMode,
2424
debugShowCheckedModeBanner: false,
25-
enableThemeAnimation: false,
25+
enableThemeAnimation: themeController.themeAnimationEnabled,
2626
enableScrollInterception: false,
2727
home: QueryaThemeScope(
2828
data: queryaTheme,

lib/core/storage/app_settings.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ abstract final class AppSettingsKeys {
5656
static const themeImportPath = 'theme_import_path';
5757
static const themeImportName = 'theme_import_name';
5858
static const themeImportedColorsJson = 'theme_imported_colors_json';
59+
static const themeAnimationEnabled = 'theme_animation_enabled';
5960
}
6061

6162
/// Bumps [listenable] when any preference is persisted so open screens can reload.
@@ -349,10 +350,34 @@ class AppSettings {
349350
AppSettingsRevision.bump();
350351
}
351352

353+
/// Smooth color transitions when switching theme (off by default).
354+
Future<bool> getThemeAnimationEnabled() async {
355+
final v = await LocalDb.instance.getAppSetting(
356+
AppSettingsKeys.themeAnimationEnabled,
357+
);
358+
if (v == null || v.isEmpty) return false;
359+
return v == 'true' || v == '1';
360+
}
361+
362+
Future<void> setThemeAnimationEnabled(bool enabled) async {
363+
if (!enabled) {
364+
await LocalDb.instance.deleteAppSetting(
365+
AppSettingsKeys.themeAnimationEnabled,
366+
);
367+
} else {
368+
await LocalDb.instance.setAppSetting(
369+
AppSettingsKeys.themeAnimationEnabled,
370+
'true',
371+
);
372+
}
373+
AppSettingsRevision.bump();
374+
}
375+
352376
Future<void> clearThemeSettings() async {
353377
await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeMode);
354378
await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themePreset);
355379
await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeOverridesJson);
380+
await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeAnimationEnabled);
356381
await deleteThemeImportKeys();
357382
AppSettingsRevision.bump();
358383
}

lib/core/theme/theme_controller.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,13 @@ class ThemeController extends ChangeNotifier {
2323
Map<String, String> _userOverrides = const {};
2424
String? _importedThemeName;
2525
bool _loaded = false;
26+
bool _themeAnimationEnabled = false;
2627

2728
ThemeMode get themeMode => _themeMode;
2829

30+
/// When true, [QueryaApp] enables ShadcnAnimatedTheme transitions.
31+
bool get themeAnimationEnabled => _themeAnimationEnabled;
32+
2933
QueryaThemePreset get preset => _preset;
3034

3135
bool get isLoaded => _loaded;
@@ -99,10 +103,18 @@ class ThemeController extends ChangeNotifier {
99103
_preset = preset;
100104
_userOverrides = Map.unmodifiable(overrides);
101105
_importedColors = Map.unmodifiable(imported);
106+
_themeAnimationEnabled =
107+
await AppSettings.instance.getThemeAnimationEnabled();
102108
_loaded = true;
103109
notifyListeners();
104110
}
105111

112+
Future<void> setThemeAnimationEnabled(bool enabled) async {
113+
_themeAnimationEnabled = enabled;
114+
await AppSettings.instance.setThemeAnimationEnabled(enabled);
115+
notifyListeners();
116+
}
117+
106118
Future<void> setThemeMode(ThemeMode mode) async {
107119
_themeMode = mode;
108120
if (_preset != QueryaThemePreset.imported) {
@@ -205,6 +217,7 @@ class ThemeController extends ChangeNotifier {
205217
_importedTokenColors = const [];
206218
_userOverrides = const {};
207219
_importedThemeName = null;
220+
_themeAnimationEnabled = false;
208221
notifyListeners();
209222
}
210223

lib/features/settings/preferences_appearance_section.dart

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ class _PreferencesAppearanceSectionState
8484
if (mounted) setState(() => _importError = null);
8585
}
8686

87+
Future<void> _setThemeAnimation(bool enabled) async {
88+
await _controller.setThemeAnimationEnabled(enabled);
89+
}
90+
8791
@override
8892
material.Widget build(material.BuildContext context) {
8993
final c = _controller;
@@ -158,6 +162,21 @@ class _PreferencesAppearanceSectionState
158162
],
159163
),
160164
const material.SizedBox(height: 12),
165+
material.Row(
166+
children: [
167+
const Text('Animate theme changes').small(),
168+
const material.SizedBox(width: 12),
169+
material.Switch(
170+
value: c.themeAnimationEnabled,
171+
onChanged: (v) => unawaited(_setThemeAnimation(v)),
172+
),
173+
],
174+
),
175+
const material.SizedBox(height: 4),
176+
const Text(
177+
'Smooth transitions when switching dark/light or presets. Off by default for stability.',
178+
).muted().xSmall(),
179+
const material.SizedBox(height: 12),
161180
material.Wrap(
162181
spacing: 8,
163182
runSpacing: 8,

test/core/storage/app_settings_test.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,20 @@ void main() {
201201
expect(await AppSettings.instance.getThemeMode(), ThemeMode.dark);
202202
});
203203

204+
test('theme animation defaults off and roundtrip', () async {
205+
expect(await AppSettings.instance.getThemeAnimationEnabled(), isFalse);
206+
207+
await AppSettings.instance.setThemeAnimationEnabled(true);
208+
expect(await AppSettings.instance.getThemeAnimationEnabled(), isTrue);
209+
210+
await AppSettings.instance.setThemeAnimationEnabled(false);
211+
expect(await AppSettings.instance.getThemeAnimationEnabled(), isFalse);
212+
213+
await AppSettings.instance.setThemeAnimationEnabled(true);
214+
await AppSettings.instance.clearThemeSettings();
215+
expect(await AppSettings.instance.getThemeAnimationEnabled(), isFalse);
216+
});
217+
204218
test('theme color overrides json roundtrip', () async {
205219
await AppSettings.instance.setThemeColorOverrides({
206220
'sideBar.background': '#ff0000',

test/core/theme/querya_theme_test.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,5 +88,18 @@ void main() {
8888
expect(td.brightness, Brightness.dark);
8989
expect(td.colorScheme.primary, QueryaColors.accentCyan);
9090
});
91+
92+
test('ThemeData.lerp at 0.5 matches QueryaTheme.lerp colorScheme', () {
93+
const a = QueryaTheme.darkDefault;
94+
const b = QueryaTheme.lightDefault;
95+
final qaMid = QueryaTheme.lerp(a, b, 0.5);
96+
final tdMid = ThemeData.lerp(
97+
a.toShadcnThemeData(),
98+
b.toShadcnThemeData(),
99+
0.5,
100+
);
101+
expect(tdMid.colorScheme.primary, qaMid.colorScheme.primary);
102+
expect(tdMid.colorScheme.background, qaMid.colorScheme.background);
103+
});
91104
});
92105
}

test/core/theme/theme_controller_test.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,20 @@ void main() {
117117
expect(c.preset, QueryaThemePreset.queryaDark);
118118
});
119119

120+
test('setThemeAnimationEnabled persists and reset clears', () async {
121+
final c = ThemeController.instance;
122+
await c.load();
123+
expect(c.themeAnimationEnabled, isFalse);
124+
125+
await c.setThemeAnimationEnabled(true);
126+
expect(c.themeAnimationEnabled, isTrue);
127+
expect(await AppSettings.instance.getThemeAnimationEnabled(), isTrue);
128+
129+
await c.resetToDefaults();
130+
expect(c.themeAnimationEnabled, isFalse);
131+
expect(await AppSettings.instance.getThemeAnimationEnabled(), isFalse);
132+
});
133+
120134
test('clearColorOverrides does not reset theme mode', () async {
121135
final c = ThemeController.instance;
122136
await c.setThemeMode(ThemeMode.light);

0 commit comments

Comments
 (0)