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
7 changes: 4 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,11 @@ version locally to avoid "works on my machine" drift. When bumping the pin, run

For animated UI, **do not invent magic `Duration(...)` / raw curves** in widgets.

- Use `QueryaMotion` tokens (`fast` / `standard` / `slow`) via
- Use `QueryaMotion` tokens (`fast` / `standard` / `slow` / `treeExpand`) via
`context.motionDuration` / `context.motionCurve` (or `QueryaMotion.effective*`).
- Interactive Fluid motion: `QueryaSpring` / `QueryaSpringController` when
`QueryaSpring.springsEnabled` (Full motion only).
- **Real springs only:** `QueryaSpring` / `QueryaSpringController` when
`QueryaSpring.springsEnabled` (Full motion) — tab indicator, drag settle, etc.
Do not use `springsEnabled` just to pick an emphasized cubic for fades/dialogs.
- Honor Preferences Motion Full / Reduced / Off and OS `disableAnimations`.
- Mid-drag layout (split panes) stays 1:1; spring settle only on drag-end.
- Do not animate virtualized grid rows on scroll.
Expand Down
15 changes: 15 additions & 0 deletions docs/motion-and-high-refresh.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ Introduce `lib/core/motion/` with a single source of truth for durations and cur
- **Theme switch**: enable a tasteful `emphasized` cross-fade and consider making it on-by-default.
- **List/grid item insertion** (results, history): subtle staggered fade-in for first paint only (no per-scroll cost).

### 4.5 Springs vs duration-token cubics (#481)

`QueryaSpring.springsEnabled` (Full motion only) means **real** `SpringSimulation` /
`QueryaSpringController` — tab strip indicator, split drag settle, and similar
interruptible physics.

Shell morphs (`QueryaFadeSlide`, `QueryaSwitchingBody`, `showAppDialog`) always use
duration tokens (`standard` + `enter`/`exit`). Do **not** treat `emphasized` cubic
as a stand-in for “Fluid spring.”

---

## 5. Implementation plan (proposed issues)
Expand Down Expand Up @@ -156,6 +166,11 @@ When reviewing PRs that touch animation:
5. Tree expand: chevron `AnimatedRotation` and `QueryaAnimatedExpand` **must** use
`QueryaMotion.treeExpand` + `treeExpandCurve` (not `fast`/`standardCurve` mixed
with `standard`/`enter`).
6. **Springs vs cubics (#481):** `QueryaSpring.springsEnabled` gates **real**
`SpringSimulation` / `QueryaSpringController` only (tab strip indicator, split
drag settle). Shell morphs (`QueryaFadeSlide`, `QueryaSwitchingBody`,
`showAppDialog`) use duration-token cubics (`standard`/`enter`/`exit`) — do not
brand emphasized ease as “spring”.

**Allowed named non-token durations** (named + documented — not magic literals at call sites):

Expand Down
15 changes: 5 additions & 10 deletions lib/core/motion/querya_fade_slide.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import 'package:flutter/material.dart';

import 'querya_motion.dart';
import 'querya_motion_context.dart';
import 'querya_spring.dart';

/// Fades and optionally slides [child] when the keyed child changes.
///
/// Uses a short spring-like curve when [QueryaSpring.springsEnabled], otherwise
/// duration tokens. Prefer wrapping content with a stable [Key] on [child].
/// Uses duration-token cubic curves ([QueryaMotion.standard] / [QueryaMotion.enter]),
/// not [QueryaSpring] — reserve springs for interruptible physics (tab indicator,
/// drag settle). Prefer wrapping content with a stable [Key] on [child].
class QueryaFadeSlide extends StatelessWidget {
const QueryaFadeSlide({
super.key,
Expand All @@ -24,13 +24,8 @@ class QueryaFadeSlide extends StatelessWidget {

@override
Widget build(BuildContext context) {
final useSpring = QueryaSpring.springsEnabled(context);
final duration = context.motionDuration(
useSpring ? QueryaMotion.standard : QueryaMotion.fast,
);
final curve = context.motionCurve(
useSpring ? QueryaMotion.emphasized : QueryaMotion.enter,
);
final duration = context.motionDuration(QueryaMotion.standard);
final curve = context.motionCurve(QueryaMotion.enter);

return AnimatedSwitcher(
duration: duration,
Expand Down
8 changes: 6 additions & 2 deletions lib/core/motion/querya_spring.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import 'querya_motion_scope.dart';
/// Spring presets for Fluid UI (interruptible / redirectable motion).
///
/// Tuned toward critically damped motion (~Apple Response 0.3–0.5s feel).
/// Use with [SpringSimulation] / [AnimationController.animateWith], not fixed
/// [Duration] curves, when [springsEnabled] is true.
/// Use **only** with [SpringSimulation] / [AnimationController.animateWith]
/// when [springsEnabled] is true (tab indicator, drag settle, etc.).
///
/// Do **not** branch on [springsEnabled] merely to pick an emphasized cubic
/// curve for [AnimatedOpacity] / [AnimatedSwitcher] / dialogs — those use
/// [QueryaMotion] duration tokens instead (#481).
abstract final class QueryaSpring {
/// Snappy panels / dialogs / tab indicator (~0.3s Response feel).
static const SpringDescription snappy = SpringDescription(
Expand Down
13 changes: 5 additions & 8 deletions lib/core/motion/querya_switching_body.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import 'package:flutter/material.dart';

import 'querya_motion.dart';
import 'querya_motion_context.dart';
import 'querya_spring.dart';

/// Keep-alive indexed stack with opacity (+ optional slide) transitions.
///
/// Off-screen children stay mounted (SQL editor state, etc.). Prefer this over
/// hard `if` swaps for empty↔workspace and similar shell morphs.
///
/// Uses duration-token cubics ([QueryaMotion.standard] / enter / exit), not
/// [QueryaSpring] — springs stay for interruptible physics only.
class QueryaSwitchingBody extends StatelessWidget {
const QueryaSwitchingBody({
super.key,
Expand All @@ -26,13 +28,8 @@ class QueryaSwitchingBody extends StatelessWidget {
Widget build(BuildContext context) {
assert(children.isNotEmpty, 'QueryaSwitchingBody requires children');
final safeIndex = index.clamp(0, children.length - 1);
final useSpring = QueryaSpring.springsEnabled(context);
final duration = context.motionDuration(
useSpring ? QueryaMotion.standard : QueryaMotion.fast,
);
final inCurve = context.motionCurve(
useSpring ? QueryaMotion.emphasized : QueryaMotion.enter,
);
final duration = context.motionDuration(QueryaMotion.standard);
final inCurve = context.motionCurve(QueryaMotion.enter);
final outCurve = context.motionCurve(QueryaMotion.exit);

return Stack(
Expand Down
6 changes: 1 addition & 5 deletions lib/shared/widgets/app_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import 'package:flutter/material.dart';

import 'package:querya_desktop/core/motion/querya_motion.dart';
import 'package:querya_desktop/core/motion/querya_motion_context.dart';
import 'package:querya_desktop/core/motion/querya_spring.dart';

/// Shows a modal dialog with a frosted, dimmed backdrop over the app.
///
Expand Down Expand Up @@ -76,12 +75,9 @@ class _BlurredDialogScaffoldState extends State<_BlurredDialogScaffold> {

void _rebuildCurved() {
_curved?.dispose();
final useSpring = QueryaSpring.springsEnabled(context);
_curved = CurvedAnimation(
parent: widget.animation,
curve: context.motionCurve(
useSpring ? QueryaMotion.emphasized : QueryaMotion.enter,
),
curve: context.motionCurve(QueryaMotion.enter),
reverseCurve: context.motionCurve(QueryaMotion.exit),
);
}
Expand Down
8 changes: 4 additions & 4 deletions test/core/motion/querya_fade_slide_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ void main() {
expect(find.text('a'), findsNothing);
});

testWidgets('uses standard duration when springs enabled (full)',
testWidgets('uses standard/enter duration tokens (full motion)',
(tester) async {
await tester.pumpWidget(
wrap(
Expand All @@ -104,10 +104,10 @@ void main() {
final switcher =
tester.widget<AnimatedSwitcher>(find.byType(AnimatedSwitcher));
expect(switcher.duration, QueryaMotion.standard);
expect(switcher.switchInCurve, QueryaMotion.emphasized);
expect(switcher.switchInCurve, QueryaMotion.enter);
});

testWidgets('uses fast duration when reduced (no springs)', (tester) async {
testWidgets('halves standard duration when reduced', (tester) async {
await tester.pumpWidget(
wrap(
const QueryaFadeSlide(
Expand All @@ -122,7 +122,7 @@ void main() {
switcher.duration,
QueryaMotion.effectiveDuration(
tester.element(find.byType(QueryaFadeSlide)),
QueryaMotion.fast,
QueryaMotion.standard,
),
);
expect(switcher.switchInCurve, QueryaMotion.enter);
Expand Down
5 changes: 2 additions & 3 deletions test/core/motion/querya_switching_body_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,7 @@ void main() {
expect(opacity.duration, QueryaMotion.instant);
});

testWidgets('reduced motion disables springs path (fast halved)',
(tester) async {
testWidgets('halves standard duration when reduced', (tester) async {
await tester.pumpWidget(
wrap(
const QueryaSwitchingBody(
Expand All @@ -246,7 +245,7 @@ void main() {
opacity.duration,
QueryaMotion.effectiveDuration(
tester.element(find.byType(QueryaSwitchingBody)),
QueryaMotion.fast,
QueryaMotion.standard,
),
);
});
Expand Down
Loading