feat: federnde Übergänge und Animationen, Entwürfe je Raum

- 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]>
This commit is contained in:
Bernd Steckmeister
2026-10-07 22:07:17 +02:00
co-authored by Claude Opus 5.5
parent 1d0ccfde8b
commit ae6f651f4c
14 changed files with 704 additions and 41 deletions
+135
View File
@@ -69,3 +69,138 @@ 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,
),
);
}
}