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
31 changes: 28 additions & 3 deletions lib/app/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import 'package:querya_desktop/core/layout/ui_scale.dart';
import 'package:querya_desktop/core/layout/ui_scale_controller.dart';
import 'package:querya_desktop/core/motion/querya_motion_controller.dart';
import 'package:querya_desktop/core/motion/querya_motion_scope.dart';
import 'package:querya_desktop/core/theme/querya_theme_scope.dart';
import 'package:querya_desktop/core/motion/querya_theme_motion.dart';
import 'package:querya_desktop/core/theme/animated_querya_theme.dart';
import 'package:querya_desktop/core/theme/theme_controller.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';

Expand Down Expand Up @@ -32,14 +33,36 @@ class QueryaApp extends StatelessWidget {
listenable: motionController,
builder: (context, _) {
final motionLevel = motionController.level;
final disableAnimations =
MediaQuery.maybeOf(context)?.disableAnimations ??
WidgetsBinding.instance.platformDispatcher
.accessibilityFeatures.disableAnimations;
final themeDuration = QueryaThemeMotion.duration(
preferenceEnabled: themeController.themeAnimationEnabled,
level: motionLevel,
disableAnimations: disableAnimations,
);
final themeCurve = QueryaThemeMotion.curve(
preferenceEnabled: themeController.themeAnimationEnabled,
level: motionLevel,
disableAnimations: disableAnimations,
);
final themeAnimEnabled = QueryaThemeMotion.enabled(
preferenceEnabled: themeController.themeAnimationEnabled,
level: motionLevel,
disableAnimations: disableAnimations,
);

return ShadcnApp(
title: 'Querya',
theme: themeController.lightShadcnTheme,
darkTheme: themeController.darkShadcnTheme,
themeMode: themeController.themeMode,
materialTheme: themeController.materialThemeFor(colorScheme),
debugShowCheckedModeBanner: false,
enableThemeAnimation: themeController.themeAnimationEnabled,
enableThemeAnimation: themeAnimEnabled,
themeAnimationDuration: themeDuration,
themeAnimationCurve: themeCurve,
enableScrollInterception: false,
// Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens.
builder: (context, child) {
Expand All @@ -55,8 +78,10 @@ class QueryaApp extends StatelessWidget {
scale,
),
),
child: QueryaThemeScope(
child: AnimatedQueryaTheme(
data: queryaTheme,
duration: themeDuration,
curve: themeCurve,
child: QueryaMotionScope(
level: motionLevel,
child: child ?? const SizedBox.shrink(),
Expand Down
60 changes: 60 additions & 0 deletions lib/core/motion/querya_theme_motion.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import 'package:flutter/animation.dart';

import 'querya_motion.dart';
import 'querya_motion_scope.dart';

/// Theme cross-fade timing gated by the Preferences toggle and motion level.
abstract final class QueryaThemeMotion {
/// Effective theme transition duration.
///
/// Returns [QueryaMotion.instant] when the preference is off, motion is
/// [QueryaMotionLevel.off], or OS `disableAnimations` is set. Reduced halves
/// [QueryaMotion.slow]; Full uses [QueryaMotion.slow].
static Duration duration({
required bool preferenceEnabled,
required QueryaMotionLevel level,
bool disableAnimations = false,
}) {
if (!preferenceEnabled ||
disableAnimations ||
level == QueryaMotionLevel.off) {
return QueryaMotion.instant;
}
const token = QueryaMotion.slow;
if (level == QueryaMotionLevel.reduced) {
return Duration(microseconds: token.inMicroseconds ~/ 2);
}
return token;
}

/// Curve for theme transitions (emphasized when animating).
static Curve curve({
required bool preferenceEnabled,
required QueryaMotionLevel level,
bool disableAnimations = false,
}) {
if (duration(
preferenceEnabled: preferenceEnabled,
level: level,
disableAnimations: disableAnimations,
) ==
QueryaMotion.instant) {
return Curves.linear;
}
return QueryaMotion.emphasized;
}

/// Whether [ShadcnAnimatedTheme] / [AnimatedQueryaTheme] should animate.
static bool enabled({
required bool preferenceEnabled,
required QueryaMotionLevel level,
bool disableAnimations = false,
}) {
return duration(
preferenceEnabled: preferenceEnabled,
level: level,
disableAnimations: disableAnimations,
) !=
QueryaMotion.instant;
}
}
54 changes: 54 additions & 0 deletions lib/core/theme/animated_querya_theme.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';

import 'querya_theme.dart';
import 'querya_theme_scope.dart';

/// Cross-fades [QueryaTheme] tokens (workbench / editor / scheme) over [duration].
///
/// When [duration] is [Duration.zero], jumps immediately (motion Off / preference).
class AnimatedQueryaTheme extends ImplicitlyAnimatedWidget {
const AnimatedQueryaTheme({
super.key,
required this.data,
required super.duration,
super.curve,
required this.child,
});

final QueryaTheme data;
final Widget child;

@override
AnimatedWidgetBaseState<AnimatedQueryaTheme> createState() =>
_AnimatedQueryaThemeState();
}

class _AnimatedQueryaThemeState
extends AnimatedWidgetBaseState<AnimatedQueryaTheme> {
QueryaThemeTween? _data;

@override
void forEachTween(TweenVisitor<dynamic> visitor) {
_data = visitor(
_data,
widget.data,
(dynamic value) => QueryaThemeTween(begin: value as QueryaTheme),
) as QueryaThemeTween?;
}

@override
Widget build(BuildContext context) {
return QueryaThemeScope(
data: _data!.evaluate(animation),
child: widget.child,
);
}
}

/// Tween that lerps [QueryaTheme] via [QueryaTheme.lerp].
class QueryaThemeTween extends Tween<QueryaTheme> {
QueryaThemeTween({super.begin, super.end});

@override
QueryaTheme lerp(double t) => QueryaTheme.lerp(begin!, end!, t);
}
5 changes: 4 additions & 1 deletion lib/core/theme/theme_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ class ThemeController extends ChangeNotifier {

ThemeMode get themeMode => _themeMode;

/// When true, [QueryaApp] enables ShadcnAnimatedTheme transitions.
/// Preference toggle for theme cross-fades.
///
/// [QueryaApp] still gates animation with [QueryaThemeMotion] (motion level /
/// OS disableAnimations). When those disallow motion, themes snap.
bool get themeAnimationEnabled => _themeAnimationEnabled;

QueryaThemePreset get preset => _preset;
Expand Down
3 changes: 2 additions & 1 deletion lib/features/settings/preferences_appearance_section.dart
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,8 @@ class _PreferencesAppearanceSectionState
),
const material.SizedBox(height: 4),
const PreferencesHint(
'Smooth transitions when switching dark/light or presets. Off by default for stability.',
'Smooth transitions when switching dark/light or presets. Off by default '
'for stability. Also requires Motion Full or Reduced (Off snaps themes).',
),
const material.SizedBox(height: 12),
PreferencesFieldRow(
Expand Down
101 changes: 101 additions & 0 deletions test/core/motion/querya_theme_motion_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import 'package:flutter/animation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:querya_desktop/core/motion/querya_motion.dart';
import 'package:querya_desktop/core/motion/querya_motion_scope.dart';
import 'package:querya_desktop/core/motion/querya_theme_motion.dart';

void main() {
group('QueryaThemeMotion.duration', () {
test('instant when preference disabled', () {
expect(
QueryaThemeMotion.duration(
preferenceEnabled: false,
level: QueryaMotionLevel.full,
),
QueryaMotion.instant,
);
});

test('instant when motion off', () {
expect(
QueryaThemeMotion.duration(
preferenceEnabled: true,
level: QueryaMotionLevel.off,
),
QueryaMotion.instant,
);
});

test('instant when OS disables animations', () {
expect(
QueryaThemeMotion.duration(
preferenceEnabled: true,
level: QueryaMotionLevel.full,
disableAnimations: true,
),
QueryaMotion.instant,
);
});

test('slow for full motion', () {
expect(
QueryaThemeMotion.duration(
preferenceEnabled: true,
level: QueryaMotionLevel.full,
),
QueryaMotion.slow,
);
});

test('halved slow for reduced motion', () {
expect(
QueryaThemeMotion.duration(
preferenceEnabled: true,
level: QueryaMotionLevel.reduced,
),
Duration(microseconds: QueryaMotion.slow.inMicroseconds ~/ 2),
);
});
});

group('QueryaThemeMotion.curve', () {
test('linear when not animating', () {
expect(
QueryaThemeMotion.curve(
preferenceEnabled: false,
level: QueryaMotionLevel.full,
),
Curves.linear,
);
});

test('emphasized when animating', () {
expect(
QueryaThemeMotion.curve(
preferenceEnabled: true,
level: QueryaMotionLevel.full,
),
QueryaMotion.emphasized,
);
});
});

group('QueryaThemeMotion.enabled', () {
test('true only when duration is non-zero', () {
expect(
QueryaThemeMotion.enabled(
preferenceEnabled: true,
level: QueryaMotionLevel.full,
),
isTrue,
);
expect(
QueryaThemeMotion.enabled(
preferenceEnabled: true,
level: QueryaMotionLevel.off,
),
isFalse,
);
});
});
}
Loading
Loading