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
62 changes: 62 additions & 0 deletions lib/core/motion/querya_fade_slide.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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].
class QueryaFadeSlide extends StatelessWidget {
const QueryaFadeSlide({
super.key,
required this.child,
this.offset = const Offset(0, 0.02),
this.alignment = Alignment.center,
});

final Widget child;

/// Fractional slide for the incoming child (of parent size).
final Offset offset;
final Alignment alignment;

@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,
);

return AnimatedSwitcher(
duration: duration,
switchInCurve: curve,
switchOutCurve: context.motionCurve(QueryaMotion.exit),
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: alignment,
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
transitionBuilder: (child, animation) {
final slide = Tween<Offset>(begin: offset, end: Offset.zero)
.animate(animation);
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: slide,
child: child,
),
);
},
child: child,
);
}
}
71 changes: 71 additions & 0 deletions lib/core/motion/querya_hover_surface.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';

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

/// Unified hover background / border using motion tokens (Responsive chrome).
class QueryaHoverSurface extends StatefulWidget {
const QueryaHoverSurface({
super.key,
required this.child,
this.borderRadius,
this.padding,
this.hoveredColor,
this.idleColor = Colors.transparent,
this.onTap,
this.mouseCursor,
});

final Widget child;
final BorderRadius? borderRadius;
final EdgeInsetsGeometry? padding;
final Color? hoveredColor;
final Color idleColor;
final VoidCallback? onTap;
final MouseCursor? mouseCursor;

@override
State<QueryaHoverSurface> createState() => _QueryaHoverSurfaceState();
}

class _QueryaHoverSurfaceState extends State<QueryaHoverSurface> {
bool _hovered = false;

@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final hovered = widget.hoveredColor ??
scheme.onSurface.withValues(alpha: 0.06);
final duration = context.motionDuration(QueryaMotion.fast);
final curve = context.motionCurve(QueryaMotion.enter);

Widget content = AnimatedContainer(
duration: duration,
curve: curve,
padding: widget.padding,
decoration: BoxDecoration(
color: _hovered ? hovered : widget.idleColor,
borderRadius: widget.borderRadius,
),
child: widget.child,
);

if (widget.onTap != null) {
content = GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onTap,
child: content,
);
}

return MouseRegion(
cursor: widget.mouseCursor ??
(widget.onTap != null
? SystemMouseCursors.click
: SystemMouseCursors.basic),
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: content,
);
}
}
4 changes: 4 additions & 0 deletions lib/core/motion/querya_motion_context.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';

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

extension QueryaMotionContext on BuildContext {
/// Token duration after OS / in-app reduced-motion rules.
Expand All @@ -9,4 +10,7 @@ extension QueryaMotionContext on BuildContext {

/// Token curve after OS / in-app reduced-motion rules.
Curve motionCurve(Curve token) => QueryaMotion.effectiveCurve(this, token);

/// Whether interactive surfaces should use spring physics (Full only).
bool get motionSpringsEnabled => QueryaSpring.springsEnabled(this);
}
55 changes: 55 additions & 0 deletions lib/core/motion/querya_spring.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';

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.
abstract final class QueryaSpring {
/// Snappy panels / dialogs / tab indicator (~0.3s Response feel).
static const SpringDescription snappy = SpringDescription(
mass: 1,
stiffness: 400,
damping: 40, // ≈ 2 * sqrt(stiffness * mass) — critically damped
);

/// Softer sheet / sidebar settle (~0.5s Response feel).
static const SpringDescription gentle = SpringDescription(
mass: 1,
stiffness: 180,
damping: 26.83,
);

/// Slight underdamped bounce for flicks / inertial settles.
static const SpringDescription bouncy = SpringDescription(
mass: 1,
stiffness: 300,
damping: 20,
);

/// Whether interactive surfaces should use springs (Full motion only).
///
/// Reduced / Off / OS `disableAnimations` fall back to duration tokens or
/// instant transitions.
static bool springsEnabled(BuildContext context) {
if (MediaQuery.disableAnimationsOf(context)) return false;
final level = QueryaMotionScope.maybeOf(context);
return level == null || level == QueryaMotionLevel.full;
}

/// Builds a [SpringSimulation] from [start] toward [end].
///
/// Pass [velocity] from the previous simulation / gesture for handoff
/// (redirectable / interruptible).
static SpringSimulation simulation({
required SpringDescription description,
required double start,
required double end,
double velocity = 0,
}) {
return SpringSimulation(description, start, end, velocity);
}
}
112 changes: 112 additions & 0 deletions lib/core/motion/querya_spring_controller.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import 'package:flutter/animation.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/scheduler.dart';

import 'querya_spring.dart';

/// Drives a scalar with interruptible / redirectable spring motion.
///
/// Call [animateTo] to retarget; the current presentation value and velocity
/// are preserved (no brick-wall). When [useSprings] is false, snaps via
/// [jumpTo].
class QueryaSpringController extends ChangeNotifier {
QueryaSpringController({
required TickerProvider vsync,
double value = 0,
this.spring = QueryaSpring.snappy,
this.useSprings = true,
}) : _value = value,
_target = value {
_ticker = vsync.createTicker(_onTick);
}

SpringDescription spring;
bool useSprings;

late final Ticker _ticker;
double _value;
double _velocity = 0;
double _target;
SpringSimulation? _simulation;
Duration? _simulationStart;

double get value => _value;
double get velocity => _velocity;
double get target => _target;
bool get isAnimating => _ticker.isActive;

/// Instantly sets value (and clears velocity).
void jumpTo(double value) {
_ticker.stop();
_simulation = null;
_simulationStart = null;
_velocity = 0;
_target = value;
if (_value == value) return;
_value = value;
notifyListeners();
}

/// Animates toward [target], inheriting current velocity when redirecting.
void animateTo(double target, {double? velocity}) {
_target = target;
final startVelocity = velocity ?? _velocity;

if (!useSprings) {
jumpTo(target);
return;
}

if ((_value - target).abs() < 0.0001 && startVelocity.abs() < 0.0001) {
jumpTo(target);
return;
}

_simulation = QueryaSpring.simulation(
description: spring,
start: _value,
end: target,
velocity: startVelocity,
);
_simulationStart = null;
if (!_ticker.isActive) {
_ticker.start();
}
}

void _onTick(Duration elapsed) {
final simulation = _simulation;
if (simulation == null) {
_ticker.stop();
return;
}

_simulationStart ??= elapsed;
final t = (elapsed - _simulationStart!).inMicroseconds / 1e6;
final next = simulation.x(t);
_velocity = simulation.dx(t);

final settled = simulation.isDone(t) ||
((next - _target).abs() < 0.0005 && _velocity.abs() < 0.01);

if (settled) {
_value = _target;
_velocity = 0;
_simulation = null;
_simulationStart = null;
_ticker.stop();
notifyListeners();
return;
}

_value = next;
notifyListeners();
}

@override
void dispose() {
_ticker.dispose();
super.dispose();
}
}
Loading
Loading