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 createState() => _MobilePageStackState(); } class _MobilePageStackState extends State 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, ), ), ), ), ], ); }, ); }, ); } }