Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 23 additions & 18 deletions lib/app/app.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(),
),
),
);
},
);
}
}
48 changes: 47 additions & 1 deletion lib/core/storage/app_settings.dart
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -170,4 +173,47 @@ class AppSettings {
);
AppSettingsRevision.bump();
}

/// UI theme mode (dark / light / system).
Future<ThemeMode> getThemeMode() async {
final v = await LocalDb.instance.getAppSetting(AppSettingsKeys.themeMode);
return switch (v) {
'light' => ThemeMode.light,
'system' => ThemeMode.system,
_ => ThemeMode.dark,
};
}

Future<void> 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<QueryaThemePreset> getThemePreset() async {
final v = await LocalDb.instance.getAppSetting(AppSettingsKeys.themePreset);
return switch (v) {
'querya_light' => QueryaThemePreset.queryaLight,
_ => QueryaThemePreset.queryaDark,
};
}

Future<void> 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<void> clearThemeSettings() async {
await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themeMode);
await LocalDb.instance.deleteAppSetting(AppSettingsKeys.themePreset);
AppSettingsRevision.bump();
}
}
2 changes: 1 addition & 1 deletion lib/core/theme/parser/color_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions lib/core/theme/querya_theme_preset.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// Built-in theme presets (imported VS Code themes — #44).
enum QueryaThemePreset {
queryaDark,
queryaLight,
}
76 changes: 76 additions & 0 deletions lib/core/theme/theme_controller.dart
Original file line number Diff line number Diff line change
@@ -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<void> load() async {
final mode = await AppSettings.instance.getThemeMode();
final preset = await AppSettings.instance.getThemePreset();
_themeMode = mode;
_preset = preset;
_loaded = true;
notifyListeners();
}

Future<void> 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<void> 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<void> resetToDefaults() async {
await AppSettings.instance.clearThemeSettings();
_themeMode = ThemeMode.dark;
_preset = QueryaThemePreset.queryaDark;
notifyListeners();
}
}
2 changes: 2 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 24 additions & 0 deletions test/core/storage/app_settings_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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', () {
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion test/core/theme/parser/color_parser_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down
78 changes: 78 additions & 0 deletions test/core/theme/theme_controller_test.dart
Original file line number Diff line number Diff line change
@@ -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<String?> getApplicationSupportPath() async => _root;

@override
Future<String?> getTemporaryPath() async => _root;

@override
Future<String?> 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);
});
}
Loading