feat: Handy - groessere Symbole, Suche/Mitglieder im Menue, Kontakt-Banner repariert, Schrift 110 %, federnde Knoepfe

Bernds Feedback 2026-10-07:
- Schrift-Standard Handy 110 % (statt 115 %).
- PyrIconBtn: im Handy-Layout Symbol 22 px (vorher fest 16), Flaeche 40,
  Tippflaeche 44; Farben aus dem Farbmodus statt fest verdrahtet.
- Eingabeleiste: Anhaengen/GIF/Emoji/Senden auf dem Handy 22-23 px statt
  12-13 px.
- Chat-Kopf (Handy): Anrufen + Videoanruf sichtbar, Suchen + Mitglieder im
  Drei-Punkte-Menue.
- Aufklappbarer DM-Kopf als Ebenen: Zurueck + Symbole bleiben oben,
  Profilbereich gleitet darunter und nutzt die volle Breite (vorher Name zu
  "p..." gekuerzt, Symbole rutschten in die Mitte); Kuerzel im Avatar
  mittig, skaliert mit.
- SpringPress (widgets/spring_press.dart): federnde Druck-Rueckmeldung,
  aus bei "Animationen 0 %".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-07 18:13:32 +02:00
co-authored by Claude Opus 5.5
parent 36875ef9cd
commit 382747cdb1
6 changed files with 463 additions and 245 deletions
@@ -63,6 +63,21 @@ void main() {
await _scrollToNewest(tester); await _scrollToNewest(tester);
await _shot(tester, '02_chat'); await _shot(tester, '02_chat');
// Kontakt-Banner aufklappen (Tipp auf den Namen im Kopf) und wieder zu.
await tester.tap(find.text(_otherUser).first);
await _pumpFor(tester, const Duration(seconds: 1));
await _shot(tester, '02c_dm_kopf_aufgeklappt');
await tester.tap(find.text(_otherUser).first);
await _pumpFor(tester, const Duration(seconds: 1));
// ⋮-Menü mit Suchen/Mitglieder.
await tester.tap(find.byIcon(Icons.more_vert_rounded).first);
await _pumpFor(tester, const Duration(seconds: 1));
expect(find.text('Mitglieder'), findsWidgets);
await _shot(tester, '02d_menue');
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await _pumpFor(tester, const Duration(seconds: 1));
container.read(messageStyleProvider.notifier).state = MessageStyle.list; container.read(messageStyleProvider.notifier).state = MessageStyle.list;
await _pumpFor(tester, const Duration(seconds: 2)); await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '02b_chat_liste'); await _shot(tester, '02b_chat_liste');
+3 -2
View File
@@ -10,8 +10,9 @@ bool isMobileLayout(Size size) => size.width < 900 || size.height < 500;
// ── Schriftgröße ─────────────────────────────────────────────────────────── // ── Schriftgröße ───────────────────────────────────────────────────────────
/// Standard-Schriftfaktor, solange der Nutzer nichts eingestellt hat: auf dem /// Standard-Schriftfaktor, solange der Nutzer nichts eingestellt hat: auf dem
/// Handy größer (Bernds Wunsch 2026-10-07: ≈ 16 statt 14 px), am PC wie bisher. /// Handy größer (Bernd 2026-10-07: 115 % ausprobiert, 110 % als Standard
double defaultTextScale({required bool mobile}) => mobile ? 1.15 : 1.0; /// gewünscht), am PC wie bisher.
double defaultTextScale({required bool mobile}) => mobile ? 1.10 : 1.0;
const minTextScale = 0.85; const minTextScale = 0.85;
const maxTextScale = 1.5; const maxTextScale = 1.5;
+26 -10
View File
@@ -7,7 +7,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/widgets/spring_press.dart';
import 'package:pyramid/features/chat/attachment_dialog.dart'; import 'package:pyramid/features/chat/attachment_dialog.dart';
import 'package:pyramid/features/chat/emoji_picker.dart'; import 'package:pyramid/features/chat/emoji_picker.dart';
import 'package:pyramid/features/chat/gif_sticker_picker.dart'; import 'package:pyramid/features/chat/gif_sticker_picker.dart';
@@ -300,6 +302,8 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
final pt = PyramidTheme.of(context); final pt = PyramidTheme.of(context);
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
final isSmall = size.width < 400; final isSmall = size.width < 400;
// Handy: größere Symbole (vorher 12–13 px, Bernds Feedback 2026-10-07).
final mobile = isMobileLayout(size);
final roomName = widget.room.getLocalizedDisplayname(); final roomName = widget.room.getLocalizedDisplayname();
final placeholder = widget.room.isDirectChat final placeholder = widget.room.isDirectChat
@@ -347,7 +351,7 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
icon: Icons.add_rounded, icon: Icons.add_rounded,
tooltip: 'Datei anhängen', tooltip: 'Datei anhängen',
pt: pt, pt: pt,
size: size.width < 600 ? 24 : 30, size: mobile ? 42 : 30,
onTap: _pickAttachment, onTap: _pickAttachment,
), ),
// Text input // Text input
@@ -397,14 +401,14 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
icon: Icons.gif_box_outlined, icon: Icons.gif_box_outlined,
tooltip: 'GIF', tooltip: 'GIF',
pt: pt, pt: pt,
size: size.width < 600 ? 22 : 26, size: mobile ? 40 : 26,
onTap: _toggleGifPicker, onTap: _toggleGifPicker,
), ),
_ComposerIconBtn( _ComposerIconBtn(
icon: Icons.tag_faces_rounded, icon: Icons.tag_faces_rounded,
tooltip: 'Emoji', tooltip: 'Emoji',
pt: pt, pt: pt,
size: size.width < 600 ? 22 : 26, size: mobile ? 40 : 26,
onTap: _toggleEmojiPicker, onTap: _toggleEmojiPicker,
), ),
if (size.width > 600) if (size.width > 600)
@@ -424,6 +428,7 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
canSend: _canSend, canSend: _canSend,
pt: pt, pt: pt,
onTap: _send, onTap: _send,
size: mobile ? 42 : 36,
), ),
), ),
], ],
@@ -461,9 +466,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final platform = Theme.of(context).platform; final isMobile = isMobileLayout(MediaQuery.sizeOf(context));
final isMobile =
platform == TargetPlatform.android || platform == TargetPlatform.iOS;
final touchSize = isMobile ? 44.0 : widget.size; final touchSize = isMobile ? 44.0 : widget.size;
return MouseRegion( return MouseRegion(
@@ -475,6 +478,8 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
child: Tooltip( child: Tooltip(
message: widget.tooltip, message: widget.tooltip,
waitDuration: const Duration(milliseconds: 500), waitDuration: const Duration(milliseconds: 500),
child: SpringPress(
enabled: widget.onTap != null,
child: SizedBox( child: SizedBox(
width: touchSize, width: touchSize,
height: touchSize, height: touchSize,
@@ -499,6 +504,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> {
), ),
), ),
), ),
),
); );
} }
} }
@@ -507,8 +513,14 @@ class _SendButton extends StatefulWidget {
final bool canSend; final bool canSend;
final PyramidTheme pt; final PyramidTheme pt;
final VoidCallback onTap; final VoidCallback onTap;
final double size;
const _SendButton({required this.canSend, required this.pt, required this.onTap}); const _SendButton({
required this.canSend,
required this.pt,
required this.onTap,
this.size = 36,
});
@override @override
State<_SendButton> createState() => _SendButtonState(); State<_SendButton> createState() => _SendButtonState();
@@ -528,11 +540,14 @@ class _SendButtonState extends State<_SendButton> {
onExit: (_) => setState(() => _hovered = false), onExit: (_) => setState(() => _hovered = false),
child: GestureDetector( child: GestureDetector(
onTap: active ? widget.onTap : null, onTap: active ? widget.onTap : null,
child: SpringPress(
enabled: active,
pressedScale: 0.82,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
curve: Curves.easeOutBack, curve: Curves.easeOutBack,
width: 36, width: widget.size,
height: 36, height: widget.size,
decoration: BoxDecoration( decoration: BoxDecoration(
color: active ? pt.accent : pt.bg3, color: active ? pt.accent : pt.bg3,
borderRadius: BorderRadius.circular(pt.rSm), borderRadius: BorderRadius.circular(pt.rSm),
@@ -549,12 +564,13 @@ class _SendButtonState extends State<_SendButton> {
child: Center( child: Center(
child: Icon( child: Icon(
Icons.send_rounded, Icons.send_rounded,
size: 16, size: widget.size * 0.45,
color: active ? pt.accentFg : pt.fgDim, color: active ? pt.accentFg : pt.fgDim,
), ),
), ),
), ),
), ),
),
); );
} }
} }
+136 -32
View File
@@ -42,11 +42,13 @@ class _ChatHeader extends ConsumerWidget {
children: [ children: [
// Back button on mobile // Back button on mobile
if (isMobile) ...[ if (isMobile) ...[
GestureDetector( PyrIconBtn(
onTap: () => ref.read(activeRoomIdProvider.notifier).state = null, icon: Icon(Icons.arrow_back_rounded, color: pt.fg),
child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), tooltip: 'Zurück',
onPressed: () =>
ref.read(activeRoomIdProvider.notifier).state = null,
), ),
const SizedBox(width: 12), const SizedBox(width: 4),
], ],
// Room icon / avatar // Room icon / avatar
if (isDm && client != null) ...[ if (isDm && client != null) ...[
@@ -141,6 +143,13 @@ class _ChatHeader extends ConsumerWidget {
onPressed: onTogglePinned, onPressed: onTogglePinned,
), ),
], ],
if (isMobile)
_ChatOverflowMenu(
pt: pt,
onSearch: onOpenSearch,
onMembers: onToggleMembers,
)
else ...[
PyrIconBtn( PyrIconBtn(
size: 28, size: 28,
icon: const Icon(Icons.search_rounded), icon: const Icon(Icons.search_rounded),
@@ -155,6 +164,7 @@ class _ChatHeader extends ConsumerWidget {
onPressed: onToggleMembers, onPressed: onToggleMembers,
), ),
], ],
],
), ),
], ],
), ),
@@ -222,6 +232,20 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
Profile? _profile; Profile? _profile;
String? _bannerMxcUri; String? _bannerMxcUri;
// Breite der Symbolleiste (gemessen nach dem Zeichnen) – eingeklappt reicht
// der Name genau bis davor.
final _actionsKey = GlobalKey();
double _actionsWidth = 140;
void _measureActions() {
final box = _actionsKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) return;
final w = box.size.width;
if ((w - _actionsWidth).abs() > 0.5 && mounted) {
setState(() => _actionsWidth = w);
}
}
String? _statusMsg; String? _statusMsg;
bool _online = false; bool _online = false;
@@ -291,6 +315,7 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) => _measureActions());
final pt = widget.pt; final pt = widget.pt;
final expanded = widget.expanded; final expanded = widget.expanded;
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
@@ -361,21 +386,16 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
), ),
), ),
), ),
Padding( // Profilbereich: gleitet beim Aufklappen unter die oberste Zeile und
padding: const EdgeInsets.symmetric(horizontal: 16), // nutzt dann die volle Breite (vorher teilte er sie mit den Symbolen –
child: Row( // aufgeklappt wurde der Name zu „p…“ gekürzt).
children: [ AnimatedPositioned(
if (isMobile) ...[ duration: _animDuration,
GestureDetector( curve: _animCurve,
onTap: () => left: expanded ? 16 : (isMobile ? 54 : 16),
ref.read(activeRoomIdProvider.notifier).state = null, right: expanded ? 16 : _actionsWidth + (isMobile ? 14 : 24),
child: top: expanded ? 44 : 0,
Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), bottom: 0,
),
const SizedBox(width: 12),
],
// Klickbarer Profilbereich: Avatar + Name + Details
Expanded(
child: MouseRegion( child: MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
child: GestureDetector( child: GestureDetector(
@@ -399,9 +419,10 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
mxcUri: avatarUrl.toString(), mxcUri: avatarUrl.toString(),
client: client, client: client,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: _initialBox(initial, pt), placeholder:
_initialBox(initial, pt, avatarSize),
) )
: _initialBox(initial, pt), : _initialBox(initial, pt, avatarSize),
), ),
AnimatedContainer( AnimatedContainer(
duration: _animDuration, duration: _animDuration,
@@ -506,9 +527,30 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
), ),
), ),
), ),
const SizedBox(width: 12), // Zurück-Pfeil und Aktionen: fest in der obersten 52-px-Zeile –
// Aktionen — bleiben immer sichtbar // sie rutschen beim Aufklappen nicht mehr in die Mitte.
Row( if (isMobile)
Positioned(
left: 6,
top: 0,
height: 52,
child: Center(
child: PyrIconBtn(
icon: Icon(Icons.arrow_back_rounded, color: pt.fg),
tooltip: 'Zurück',
onPressed: () =>
ref.read(activeRoomIdProvider.notifier).state = null,
),
),
),
Positioned(
right: isMobile ? 6 : 16,
top: 0,
height: 52,
child: KeyedSubtree(
key: _actionsKey,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
PyrIconBtn( PyrIconBtn(
size: 28, size: 28,
@@ -537,6 +579,13 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
active: widget.pinnedOpen, active: widget.pinnedOpen,
onPressed: widget.onTogglePinned, onPressed: widget.onTogglePinned,
), ),
if (isMobile)
_ChatOverflowMenu(
pt: pt,
onSearch: widget.onOpenSearch,
onMembers: widget.onToggleMembers,
)
else ...[
PyrIconBtn( PyrIconBtn(
size: 28, size: 28,
icon: const Icon(Icons.search_rounded), icon: const Icon(Icons.search_rounded),
@@ -551,24 +600,34 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
onPressed: widget.onToggleMembers, onPressed: widget.onToggleMembers,
), ),
], ],
),
], ],
), ),
), ),
),
], ],
), ),
); );
} }
Widget _initialBox(String initial, PyramidTheme pt) => Center( // Kürzel exakt mittig: Schrift wächst mit dem Avatar mit (vorher feste
child: FittedBox( // 28 px in FittedBox mit Innenabstand – wirkte aufgeklappt verschoben).
fit: BoxFit.scaleDown, Widget _initialBox(String initial, PyramidTheme pt, double avatarSize) =>
child: Padding( Center(
padding: const EdgeInsets.all(6), child: AnimatedDefaultTextStyle(
duration: _animDuration,
curve: _animCurve,
textAlign: TextAlign.center,
style: TextStyle(
color: pt.fg,
fontSize: avatarSize * 0.42,
fontWeight: FontWeight.w700,
height: 1.0,
),
child: Text( child: Text(
initial, initial,
style: TextStyle( textHeightBehavior: const TextHeightBehavior(
color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700), applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
), ),
), ),
), ),
@@ -733,3 +792,48 @@ class _PresenceLineState extends State<_PresenceLine> {
return 'vor ${(diff.inDays / 365).floor()}j'; return 'vor ${(diff.inDays / 365).floor()}j';
} }
} }
/// Handy: Suche und Mitglieder im ⋮-Menü statt als eigene Symbole
/// (Bernds Feedback 2026-10-07 – mehr Platz, größere Hauptsymbole).
class _ChatOverflowMenu extends StatelessWidget {
final PyramidTheme pt;
final VoidCallback onSearch;
final VoidCallback onMembers;
const _ChatOverflowMenu({
required this.pt,
required this.onSearch,
required this.onMembers,
});
@override
Widget build(BuildContext context) {
PopupMenuItem<VoidCallback> item(
IconData icon, String label, VoidCallback action) =>
PopupMenuItem(
value: action,
child: Row(
children: [
Icon(icon, size: 20, color: pt.fgMuted),
const SizedBox(width: 12),
Text(label, style: TextStyle(color: pt.fg, fontSize: 14)),
],
),
);
return PopupMenuButton<VoidCallback>(
tooltip: 'Mehr',
icon: Icon(Icons.more_vert_rounded, size: 22, color: pt.fgMuted),
color: pt.bg2,
elevation: 6,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rBase),
side: BorderSide(color: pt.border),
),
onSelected: (action) => action(),
itemBuilder: (_) => [
item(Icons.search_rounded, 'Suchen', onSearch),
item(Icons.group_rounded, 'Mitglieder', onMembers),
],
);
}
}
+28 -17
View File
@@ -1,4 +1,9 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/widgets/spring_press.dart';
// Simple hover-aware widget for desktop hover effects // Simple hover-aware widget for desktop hover effects
class HoverRegion extends StatefulWidget { class HoverRegion extends StatefulWidget {
@@ -29,7 +34,10 @@ class _HoverRegionState extends State<HoverRegion> {
} }
} }
// Icon button styled to match Pyramid's .icon-btn // Icon button styled to match Pyramid's .icon-btn.
// Handy-Layout (Bernds Feedback 2026-10-07: Symbole zu klein): Symbol 22 px
// statt 16, Fläche mind. 40 px, Tippfläche mind. 44 px. Farben folgen dem
// gewählten Farbmodus, Druck federt (SpringPress).
class PyrIconBtn extends StatefulWidget { class PyrIconBtn extends StatefulWidget {
final Widget icon; final Widget icon;
final VoidCallback? onPressed; final VoidCallback? onPressed;
@@ -39,6 +47,9 @@ class PyrIconBtn extends StatefulWidget {
final Color? dangerColor; final Color? dangerColor;
final double size; final double size;
/// Symbolgröße; ohne Angabe 16 (PC) bzw. 22 (Handy-Layout).
final double? iconSize;
const PyrIconBtn({ const PyrIconBtn({
super.key, super.key,
required this.icon, required this.icon,
@@ -48,6 +59,7 @@ class PyrIconBtn extends StatefulWidget {
this.activeColor, this.activeColor,
this.dangerColor, this.dangerColor,
this.size = 32, this.size = 32,
this.iconSize,
}); });
@override @override
@@ -59,20 +71,16 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final pt = PyramidTheme.of(context);
final isDark = theme.brightness == Brightness.dark; final mobile = isMobileLayout(MediaQuery.sizeOf(context));
final fgMuted = isDark ? const Color(0xFFA4A4B0) : const Color(0xFF54545E);
final fg = isDark ? const Color(0xFFECECF0) : const Color(0xFF1A1A1F);
final bgHover = isDark ? const Color(0xFF232330) : const Color(0xFFE9E7E1);
final color = widget.active final color = widget.active
? (widget.activeColor ?? const Color(0xFFF5A614)) ? (widget.activeColor ?? pt.accent)
: _hovered ? fg : fgMuted; : _hovered ? pt.fg : pt.fgMuted;
final platform = Theme.of(context).platform; final boxSize = mobile ? math.max(widget.size, 40.0) : widget.size;
final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS; final touchSize = mobile ? math.max(boxSize, 44.0) : boxSize;
// On mobile, use a slightly larger touch target, but respect the requested size final iconSize = widget.iconSize ?? (mobile ? 22.0 : 16.0);
final touchSize = isMobile ? (widget.size + 8).clamp(32.0, 48.0) : widget.size;
final btn = MouseRegion( final btn = MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
@@ -80,6 +88,8 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
onExit: (_) => setState(() => _hovered = false), onExit: (_) => setState(() => _hovered = false),
child: GestureDetector( child: GestureDetector(
onTap: widget.onPressed, onTap: widget.onPressed,
child: SpringPress(
enabled: widget.onPressed != null,
child: SizedBox( child: SizedBox(
width: touchSize, width: touchSize,
height: touchSize, height: touchSize,
@@ -87,15 +97,15 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
width: widget.size, width: boxSize,
height: widget.size, height: boxSize,
decoration: BoxDecoration( decoration: BoxDecoration(
color: _hovered ? bgHover : Colors.transparent, color: _hovered ? pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(7), borderRadius: BorderRadius.circular(mobile ? boxSize / 2 : 7),
), ),
child: Center( child: Center(
child: IconTheme( child: IconTheme(
data: IconThemeData(color: color, size: 16), data: IconThemeData(color: color, size: iconSize),
child: widget.icon, child: widget.icon,
), ),
), ),
@@ -103,6 +113,7 @@ class _PyrIconBtnState extends State<PyrIconBtn> {
), ),
), ),
), ),
),
); );
if (widget.tooltip != null) { if (widget.tooltip != null) {
+71
View File
@@ -0,0 +1,71 @@
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());
}