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
90 changes: 83 additions & 7 deletions lib/core/motion/querya_spring_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,19 @@ 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].
/// are preserved (no brick-wall).
///
/// When [useSprings] is false:
/// - if [cubicDuration] is non-null and non-zero → duration-token cubic (#493)
/// - otherwise → snap via [jumpTo] (Off / drag settle)
class QueryaSpringController extends ChangeNotifier {
QueryaSpringController({
required TickerProvider vsync,
double value = 0,
this.spring = QueryaSpring.snappy,
this.useSprings = true,
this.cubicDuration,
this.cubicCurve = Curves.easeOutCubic,
}) : _value = value,
_target = value {
_ticker = vsync.createTicker(_onTick);
Expand All @@ -24,13 +29,21 @@ class QueryaSpringController extends ChangeNotifier {
SpringDescription spring;
bool useSprings;

/// Cubic fallback when springs are off (Reduced motion). Null / zero → snap.
Duration? cubicDuration;
Curve cubicCurve;

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

double? _cubicFrom;
Duration? _cubicTotal;
Curve? _cubicActiveCurve;

double get value => _value;
double get velocity => _velocity;
double get target => _target;
Expand All @@ -39,8 +52,7 @@ class QueryaSpringController extends ChangeNotifier {
/// Instantly sets value (and clears velocity).
void jumpTo(double value) {
_ticker.stop();
_simulation = null;
_simulationStart = null;
_clearMotion();
_velocity = 0;
_target = value;
if (_value == value) return;
Expand All @@ -54,7 +66,12 @@ class QueryaSpringController extends ChangeNotifier {
final startVelocity = velocity ?? _velocity;

if (!useSprings) {
jumpTo(target);
final duration = cubicDuration;
if (duration == null || duration == Duration.zero) {
jumpTo(target);
return;
}
_startCubic(target, duration);
return;
}

Expand All @@ -63,6 +80,9 @@ class QueryaSpringController extends ChangeNotifier {
return;
}

_cubicFrom = null;
_cubicTotal = null;
_cubicActiveCurve = null;
_simulation = QueryaSpring.simulation(
description: spring,
start: _value,
Expand All @@ -75,7 +95,37 @@ class QueryaSpringController extends ChangeNotifier {
}
}

void _startCubic(double target, Duration duration) {
if ((_value - target).abs() < 0.0001) {
jumpTo(target);
return;
}
_simulation = null;
_cubicFrom = _value;
_cubicTotal = duration;
_cubicActiveCurve = cubicCurve;
_velocity = 0;
_simulationStart = null;
if (!_ticker.isActive) {
_ticker.start();
}
}

void _clearMotion() {
_simulation = null;
_simulationStart = null;
_cubicFrom = null;
_cubicTotal = null;
_cubicActiveCurve = null;
}

void _onTick(Duration elapsed) {
final cubicTotal = _cubicTotal;
if (cubicTotal != null) {
_onCubicTick(elapsed, cubicTotal);
return;
}

final simulation = _simulation;
if (simulation == null) {
_ticker.stop();
Expand All @@ -93,8 +143,7 @@ class QueryaSpringController extends ChangeNotifier {
if (settled) {
_value = _target;
_velocity = 0;
_simulation = null;
_simulationStart = null;
_clearMotion();
_ticker.stop();
notifyListeners();
return;
Expand All @@ -104,6 +153,33 @@ class QueryaSpringController extends ChangeNotifier {
notifyListeners();
}

void _onCubicTick(Duration elapsed, Duration cubicTotal) {
_simulationStart ??= elapsed;
final micros = cubicTotal.inMicroseconds;
if (micros <= 0) {
jumpTo(_target);
return;
}
final t =
(elapsed - _simulationStart!).inMicroseconds / micros;
final from = _cubicFrom ?? _value;
final curve = _cubicActiveCurve ?? cubicCurve;

if (t >= 1) {
_value = _target;
_velocity = 0;
_clearMotion();
_ticker.stop();
notifyListeners();
return;
}

final curved = curve.transform(t.clamp(0.0, 1.0));
_value = from + (_target - from) * curved;
_velocity = 0;
notifyListeners();
}

@override
void dispose() {
_ticker.dispose();
Expand Down
19 changes: 16 additions & 3 deletions lib/shared/widgets/querya_tab_strip.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';

/// A compact, keyboard-operable tab strip using Querya's motion and theme.
///
/// Selection uses a sliding pill indicator (spring when [QueryaSpring.springsEnabled])
/// so tab changes feel continuous / redirectable.
/// Selection uses a sliding pill indicator: spring when Full
/// ([QueryaSpring.springsEnabled]), duration-token cubic under Reduced (#493),
/// snap when Off / OS `disableAnimations`.
///
/// Spring ticks rebuild only the pill ([_TabStripIndicator]), not the tab row.
/// Spring/cubic ticks rebuild only the pill ([_TabStripIndicator]), not the tab row.
class QueryaTabStrip extends material.StatefulWidget {
const QueryaTabStrip({
super.key,
Expand Down Expand Up @@ -55,6 +56,18 @@ class _QueryaTabStripState extends material.State<QueryaTabStrip>
final springs = QueryaSpring.springsEnabled(context);
_indicatorLeft.useSprings = springs;
_indicatorWidth.useSprings = springs;

// Reduced: animate with halved fast token; Off / disableAnimations → snap.
Duration? cubic;
if (!springs) {
final d = context.motionDuration(QueryaMotion.fast);
if (d > Duration.zero) cubic = d;
}
final curve = context.motionCurve(QueryaMotion.enter);
_indicatorLeft.cubicDuration = cubic;
_indicatorWidth.cubicDuration = cubic;
_indicatorLeft.cubicCurve = curve;
_indicatorWidth.cubicCurve = curve;
}

@override
Expand Down
44 changes: 44 additions & 0 deletions test/core/motion/querya_spring_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,47 @@ void main() {
expect(controller.isAnimating, isFalse);
});

testWidgets('cubics when springs off and cubicDuration set', (tester) async {
late QueryaSpringController controller;
await tester.pumpWidget(
MaterialApp(
home: _SpringHost(
useSprings: false,
cubicDuration: const Duration(milliseconds: 100),
onCreated: (c) => controller = c,
),
),
);

controller.animateTo(1);
await tester.pump();
await tester.pump(const Duration(milliseconds: 40));
expect(controller.value, greaterThan(0));
expect(controller.value, lessThan(1));
expect(controller.isAnimating, isTrue);

await tester.pumpAndSettle();
expect(controller.value, closeTo(1, 0.001));
expect(controller.isAnimating, isFalse);
});

testWidgets('cubic Duration.zero snaps', (tester) async {
late QueryaSpringController controller;
await tester.pumpWidget(
MaterialApp(
home: _SpringHost(
useSprings: false,
cubicDuration: Duration.zero,
onCreated: (c) => controller = c,
),
),
);

controller.animateTo(1);
expect(controller.value, 1);
expect(controller.isAnimating, isFalse);
});

testWidgets('notifies listeners on animate', (tester) async {
late QueryaSpringController controller;
var notifications = 0;
Expand Down Expand Up @@ -295,11 +336,13 @@ class _SpringHost extends StatefulWidget {
required this.onCreated,
this.useSprings = true,
this.spring = QueryaSpring.snappy,
this.cubicDuration,
});

final ValueChanged<QueryaSpringController> onCreated;
final bool useSprings;
final SpringDescription spring;
final Duration? cubicDuration;

@override
State<_SpringHost> createState() => _SpringHostState();
Expand All @@ -316,6 +359,7 @@ class _SpringHostState extends State<_SpringHost>
vsync: this,
useSprings: widget.useSprings,
spring: widget.spring,
cubicDuration: widget.cubicDuration,
);
widget.onCreated(_controller);
}
Expand Down
39 changes: 39 additions & 0 deletions test/shared/querya_tab_strip_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,45 @@ void main() {
);
});

testWidgets('reduced motion slides indicator with cubic (not snap)',
(tester) async {
var selected = 0;
await tester.pumpWidget(
stripShell(
level: QueryaMotionLevel.reduced,
child: material.StatefulBuilder(
builder: (context, setState) => material.Center(
child: QueryaTabStrip(
labels: const ['Server', 'SQL', 'History'],
selectedIndex: selected,
onSelected: (index) => setState(() => selected = index),
),
),
),
),
);
await tester.pump();
await tester.pumpAndSettle();

final startLeft = indicatorOf(tester).left!;

await tester.tap(find.bySemanticsLabel('History'));
await tester.pump();
await tester.pump(); // post-frame sync starts cubic
await tester.pump(const Duration(milliseconds: 20));

final midLeft = indicatorOf(tester).left!;
expect(midLeft, greaterThan(startLeft));
final history =
tester.getRect(find.byKey(const material.ValueKey('querya_tab_History')));
final strip = tester.getRect(find.byType(QueryaTabStrip));
final endLeft = history.left - strip.left;
expect(midLeft, lessThan(endLeft - 0.5));

await tester.pumpAndSettle();
expect(indicatorOf(tester).left, closeTo(endLeft, 1.0));
});

testWidgets('redirect mid-slide settles on final selection', (tester) async {
var selected = 0;
await tester.pumpWidget(
Expand Down
Loading