- 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]>
167 lines
5.5 KiB
Dart
167 lines
5.5 KiB
Dart
import 'package:flutter/physics.dart';
|
||
import 'package:flutter/widgets.dart';
|
||
import 'package:pyramid/core/theme.dart';
|
||
|
||
/// Handy-Navigation zwischen Chatliste und geöffnetem Chat.
|
||
///
|
||
/// Die Chatliste ([base]) bleibt immer aufgebaut – Scrollposition und Zustand
|
||
/// bleiben beim Zurückkehren erhalten. Der Vordergrund ([front], z. B. der
|
||
/// Chat) gleitet federnd von rechts darüber, die Liste weicht dabei leicht
|
||
/// nach links aus und dunkelt ab; beim Zurück läuft es umgekehrt. Wechselt
|
||
/// der Vordergrund direkt (anderer Chat), wird ohne Schieben getauscht.
|
||
/// Mit „Animationen: 0 %“ wird sofort umgeschaltet.
|
||
class MobilePageStack extends StatefulWidget {
|
||
const MobilePageStack({
|
||
super.key,
|
||
required this.base,
|
||
required this.front,
|
||
required this.background,
|
||
});
|
||
|
||
final Widget base;
|
||
final Widget? front;
|
||
|
||
/// Hintergrund hinter dem Vordergrund, damit die Liste beim Schieben nicht
|
||
/// durch durchsichtige Stellen scheint.
|
||
final Color background;
|
||
|
||
@override
|
||
State<MobilePageStack> createState() => _MobilePageStackState();
|
||
}
|
||
|
||
class _MobilePageStackState extends State<MobilePageStack>
|
||
with SingleTickerProviderStateMixin {
|
||
late final AnimationController _ctrl = AnimationController.unbounded(
|
||
vsync: this,
|
||
value: widget.front != null ? 1.0 : 0.0,
|
||
);
|
||
|
||
/// Angezeigter Vordergrund – bleibt beim Hinausgleiten noch stehen.
|
||
late Widget? _front = widget.front;
|
||
|
||
@override
|
||
void didUpdateWidget(MobilePageStack oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
if (widget.front != null) {
|
||
_front = widget.front;
|
||
if (oldWidget.front == null) _animateTo(1);
|
||
} else if (oldWidget.front != null) {
|
||
_animateTo(0);
|
||
}
|
||
}
|
||
|
||
void _animateTo(double target) {
|
||
final motion = PyramidTheme.of(context).motion;
|
||
if (motion < 0.05) {
|
||
_ctrl.value = target;
|
||
_settle();
|
||
return;
|
||
}
|
||
// Feder knapp unter kritischer Dämpfung: zügig, weich auslaufend.
|
||
// Kleinere „Animationen“-Werte = steifere Feder = schneller.
|
||
final spring = SpringDescription.withDampingRatio(
|
||
mass: 1,
|
||
stiffness: 520 / (motion * motion),
|
||
ratio: 0.86,
|
||
);
|
||
_ctrl
|
||
.animateWith(
|
||
SpringSimulation(spring, _ctrl.value, target, _ctrl.velocity),
|
||
)
|
||
.then((_) {
|
||
_ctrl.value = target;
|
||
_settle();
|
||
});
|
||
}
|
||
|
||
void _settle() {
|
||
if (mounted && widget.front == null && _front != null) {
|
||
setState(() => _front = null);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_ctrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final width = constraints.maxWidth;
|
||
return AnimatedBuilder(
|
||
animation: _ctrl,
|
||
builder: (context, _) {
|
||
final front = _front;
|
||
final shown = _ctrl.value.clamp(0.0, 1.0);
|
||
// Vollständig verdeckt: Liste nicht mehr zeichnen/animieren
|
||
// (bleibt aber aufgebaut).
|
||
final covered = front != null && shown >= 0.999;
|
||
return Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
KeyedSubtree(
|
||
key: const ValueKey('base'),
|
||
child: IgnorePointer(
|
||
// Nur gesperrt, solange der Vordergrund offen ist bzw.
|
||
// hereinkommt – beim Hinausgleiten ist die Liste schon
|
||
// wieder bedienbar (schneller Tipp auf den nächsten Chat).
|
||
ignoring: widget.front != null,
|
||
child: TickerMode(
|
||
enabled: !covered,
|
||
child: Offstage(
|
||
offstage: covered,
|
||
child: Transform.translate(
|
||
offset: Offset(-0.25 * width * shown, 0),
|
||
child: widget.base,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (front != null && !covered && shown > 0)
|
||
Positioned.fill(
|
||
key: const ValueKey('scrim'),
|
||
child: IgnorePointer(
|
||
child: ColoredBox(
|
||
color: const Color(0xFF000000)
|
||
.withValues(alpha: 0.30 * shown),
|
||
),
|
||
),
|
||
),
|
||
if (front != null)
|
||
KeyedSubtree(
|
||
key: const ValueKey('front'),
|
||
child: Transform.translate(
|
||
offset: Offset(width * (1 - shown), 0),
|
||
child: DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
color: widget.background,
|
||
boxShadow: covered
|
||
? null
|
||
: const [
|
||
BoxShadow(
|
||
color: Color(0x40000000),
|
||
blurRadius: 18,
|
||
),
|
||
],
|
||
),
|
||
// Beim Hinausgleiten nicht mehr bedienbar.
|
||
child: IgnorePointer(
|
||
ignoring: widget.front == null,
|
||
child: front,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|