- Handy: Chat gleitet federnd über die Chatliste (Liste bleibt aufgebaut, Scrollposition erhalten), beim Zurück wieder hinaus; Liste schon während des Hinausgleitens bedienbar. Mitgliederleiste gleitet herein. - Neue/gesendete Nachrichten ploppen weich auf (nur live, nicht beim Öffnen oder Nachladen), Reaktionen ploppen auf und hüpfen beim Hochzählen, Ungelesen-Zähler hüpft, Scroll-nach-unten-Knopf federt (Handy größer), Antwort-Leiste klappt federnd auf/zu, Senden-Pfeil ploppt, Vollansicht zoomt auf. Alles skaliert mit Einstellung „Animationen“ (0 % = aus). - Fix: Beim Raumwechsel blieben Antwort-Ziel, Auswahl und getippter Text am neuen Raum hängen (Antwort auf Raum A konnte in Raum B rausgehen). Jetzt eigenes Eingabefeld je Raum mit Entwurf im Speicher. - Antwort-Leiste: größeres Schließen-X auf dem Handy. - Tests: test/spring_motion_test.dart, UI-Durchlauf mit Übergängen. Co-Authored-By: Claude Opus 5.5 <[email protected]>
207 lines
6.0 KiB
Dart
207 lines
6.0 KiB
Dart
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<SpringPress> createState() => _SpringPressState();
|
||
}
|
||
|
||
class _SpringPressState extends State<SpringPress> {
|
||
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<PopIn> createState() => _PopInState();
|
||
}
|
||
|
||
class _PopInState extends State<PopIn> 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<SpringBump> createState() => _SpringBumpState();
|
||
}
|
||
|
||
class _SpringBumpState extends State<SpringBump>
|
||
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,
|
||
),
|
||
);
|
||
}
|
||
}
|