import 'package:flutter/widgets.dart'; import 'package:pyramid/core/theme.dart'; /// Federnde Druck-Rückmeldung: beim Drücken leicht kleiner, beim Loslassen /// elastisch zurück (Bernds Wunsch 2026-10-07: mehr „Springiness“). /// /// Hört nur passiv mit (Listener) – Tippen, Halten und Scrollen bleiben beim /// Kind bzw. beim umgebenden Scrollbereich. Bewegt sich der Finger (Scrollen), /// wird die Rückmeldung sofort abgebrochen. Mit „Animationen: 0 %“ in den /// Einstellungen (PyramidTheme.motion) ist sie aus. class SpringPress extends StatefulWidget { const SpringPress({ super.key, required this.child, this.pressedScale = 0.88, this.enabled = true, }); final Widget child; final double pressedScale; final bool enabled; @override State createState() => _SpringPressState(); } class _SpringPressState extends State { static const _moveSlop = 8.0; bool _pressed = false; Offset? _down; void _set(bool pressed) { if (_pressed != pressed && mounted) setState(() => _pressed = pressed); } @override Widget build(BuildContext context) { final motion = PyramidTheme.of(context).motion; if (!widget.enabled || motion < 0.05) return widget.child; return Listener( behavior: HitTestBehavior.translucent, onPointerDown: (e) { _down = e.position; _set(true); }, onPointerMove: (e) { final d = _down; if (d != null && (e.position - d).distance > _moveSlop) _set(false); }, onPointerUp: (_) => _set(false), onPointerCancel: (_) => _set(false), child: AnimatedScale( scale: _pressed ? widget.pressedScale : 1.0, duration: _pressed ? const Duration(milliseconds: 90) : Duration(milliseconds: (420 * motion).round().clamp(120, 420)), curve: _pressed ? Curves.easeOut : Curves.elasticOut, child: widget.child, ), ); } } /// Gemeinsame „federnde“ Kurve für Ein-/Ausblendungen (leichtes Überschwingen). const springyCurve = Cubic(0.18, 0.89, 0.32, 1.12); /// Dauer, skaliert mit der Einstellung „Animationen“ (0 = sofort). Duration motionDuration(BuildContext context, int milliseconds) { final motion = PyramidTheme.of(context).motion; return Duration(milliseconds: (milliseconds * motion).round()); } /// Einmaliges federndes Einblenden (z. B. eine gerade angekommene Nachricht): /// kommt etwas kleiner und tiefer herein und schwingt leicht über. /// /// Ob gespielt wird, entscheidet sich nur beim ersten Aufbau ([play]); danach /// bleibt das Kind unverändert, auch wenn [play] sich ändert. Mit /// „Animationen: 0 %“ wird nie gespielt. class PopIn extends StatefulWidget { const PopIn({ super.key, required this.child, this.play = true, this.alignment = Alignment.bottomCenter, this.offsetY = 18, this.fromScale = 0.86, this.milliseconds = 460, }); final Widget child; final bool play; final Alignment alignment; final double offsetY; final double fromScale; final int milliseconds; @override State createState() => _PopInState(); } class _PopInState extends State with SingleTickerProviderStateMixin { AnimationController? _ctrl; bool _decided = false; @override void didChangeDependencies() { super.didChangeDependencies(); if (_decided) return; _decided = true; final motion = PyramidTheme.of(context).motion; if (!widget.play || motion < 0.05) return; _ctrl = AnimationController( vsync: this, duration: Duration(milliseconds: (widget.milliseconds * motion).round()), )..forward(); } @override void dispose() { _ctrl?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final ctrl = _ctrl; if (ctrl == null) return widget.child; return AnimatedBuilder( animation: ctrl, child: widget.child, builder: (context, child) { final t = ctrl.value; final move = Curves.easeOutBack.transform(t); final fade = Curves.easeOut.transform((t / 0.4).clamp(0.0, 1.0)); return Opacity( opacity: fade, child: Transform.translate( offset: Offset(0, (1 - move) * widget.offsetY), child: Transform.scale( scale: widget.fromScale + (1 - widget.fromScale) * move, alignment: widget.alignment, child: child, ), ), ); }, ); } } /// Federndes „Aufploppen“, sobald [value] steigt (z. B. Ungelesen-Zähler, /// Anzahl einer Reaktion). Beim ersten Aufbau passiert nichts – so ploppt /// beim Scrollen durch eine Liste nichts grundlos auf. class SpringBump extends StatefulWidget { const SpringBump({ super.key, required this.value, required this.child, this.alignment = Alignment.center, }); final num value; final Widget child; final Alignment alignment; @override State createState() => _SpringBumpState(); } class _SpringBumpState extends State with SingleTickerProviderStateMixin { late final AnimationController _ctrl = AnimationController(vsync: this, value: 1); double _from = 1; @override void didUpdateWidget(SpringBump oldWidget) { super.didUpdateWidget(oldWidget); if (widget.value <= oldWidget.value) return; final motion = PyramidTheme.of(context).motion; if (motion < 0.05) return; // Aus dem Nichts (0 → 1) größer aufploppen als beim Hochzählen. _from = oldWidget.value <= 0 ? 0.3 : 0.7; _ctrl.duration = Duration(milliseconds: (650 * motion).round()); _ctrl.forward(from: 0); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _ctrl, child: widget.child, builder: (context, child) => Transform.scale( scale: _from + (1 - _from) * Curves.elasticOut.transform(_ctrl.value), alignment: widget.alignment, child: child, ), ); } }