From 382747cdb1731d87775090ebfb1287b5e6ad598b Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Wed, 7 Oct 2026 18:13:32 +0200 Subject: [PATCH] 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 --- integration_test/mobile_ui_screens_test.dart | 15 + lib/core/appearance.dart | 5 +- lib/features/chat/chat_composer.dart | 40 +- lib/features/chat/view/chat_header.dart | 508 +++++++++++-------- lib/widgets/hover_region.dart | 69 +-- lib/widgets/spring_press.dart | 71 +++ 6 files changed, 463 insertions(+), 245 deletions(-) create mode 100644 lib/widgets/spring_press.dart diff --git a/integration_test/mobile_ui_screens_test.dart b/integration_test/mobile_ui_screens_test.dart index ce1f3a4..8c6297d 100644 --- a/integration_test/mobile_ui_screens_test.dart +++ b/integration_test/mobile_ui_screens_test.dart @@ -63,6 +63,21 @@ void main() { await _scrollToNewest(tester); 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; await _pumpFor(tester, const Duration(seconds: 2)); await _shot(tester, '02b_chat_liste'); diff --git a/lib/core/appearance.dart b/lib/core/appearance.dart index 52f1d67..c4539a0 100644 --- a/lib/core/appearance.dart +++ b/lib/core/appearance.dart @@ -10,8 +10,9 @@ bool isMobileLayout(Size size) => size.width < 900 || size.height < 500; // ── Schriftgröße ─────────────────────────────────────────────────────────── /// 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. -double defaultTextScale({required bool mobile}) => mobile ? 1.15 : 1.0; +/// Handy größer (Bernd 2026-10-07: 115 % ausprobiert, 110 % als Standard +/// gewünscht), am PC wie bisher. +double defaultTextScale({required bool mobile}) => mobile ? 1.10 : 1.0; const minTextScale = 0.85; const maxTextScale = 1.5; diff --git a/lib/features/chat/chat_composer.dart b/lib/features/chat/chat_composer.dart index 6f708c3..77c2c99 100644 --- a/lib/features/chat/chat_composer.dart +++ b/lib/features/chat/chat_composer.dart @@ -7,7 +7,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:matrix/matrix.dart'; import 'package:path_provider/path_provider.dart'; import 'package:pyramid/core/settings_prefs.dart'; +import 'package:pyramid/core/appearance.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/emoji_picker.dart'; import 'package:pyramid/features/chat/gif_sticker_picker.dart'; @@ -300,6 +302,8 @@ class _ChatComposerState extends ConsumerState { final pt = PyramidTheme.of(context); final size = MediaQuery.sizeOf(context); 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 placeholder = widget.room.isDirectChat @@ -347,7 +351,7 @@ class _ChatComposerState extends ConsumerState { icon: Icons.add_rounded, tooltip: 'Datei anhängen', pt: pt, - size: size.width < 600 ? 24 : 30, + size: mobile ? 42 : 30, onTap: _pickAttachment, ), // Text input @@ -397,14 +401,14 @@ class _ChatComposerState extends ConsumerState { icon: Icons.gif_box_outlined, tooltip: 'GIF', pt: pt, - size: size.width < 600 ? 22 : 26, + size: mobile ? 40 : 26, onTap: _toggleGifPicker, ), _ComposerIconBtn( icon: Icons.tag_faces_rounded, tooltip: 'Emoji', pt: pt, - size: size.width < 600 ? 22 : 26, + size: mobile ? 40 : 26, onTap: _toggleEmojiPicker, ), if (size.width > 600) @@ -424,6 +428,7 @@ class _ChatComposerState extends ConsumerState { canSend: _canSend, pt: pt, onTap: _send, + size: mobile ? 42 : 36, ), ), ], @@ -461,9 +466,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> { @override Widget build(BuildContext context) { - final platform = Theme.of(context).platform; - final isMobile = - platform == TargetPlatform.android || platform == TargetPlatform.iOS; + final isMobile = isMobileLayout(MediaQuery.sizeOf(context)); final touchSize = isMobile ? 44.0 : widget.size; return MouseRegion( @@ -475,7 +478,9 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> { child: Tooltip( message: widget.tooltip, waitDuration: const Duration(milliseconds: 500), - child: SizedBox( + child: SpringPress( + enabled: widget.onTap != null, + child: SizedBox( width: touchSize, height: touchSize, child: Center( @@ -497,6 +502,7 @@ class _ComposerIconBtnState extends State<_ComposerIconBtn> { ), ), ), + ), ), ), ); @@ -507,8 +513,14 @@ class _SendButton extends StatefulWidget { final bool canSend; final PyramidTheme pt; 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 State<_SendButton> createState() => _SendButtonState(); @@ -528,11 +540,14 @@ class _SendButtonState extends State<_SendButton> { onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: active ? widget.onTap : null, - child: AnimatedContainer( + child: SpringPress( + enabled: active, + pressedScale: 0.82, + child: AnimatedContainer( duration: const Duration(milliseconds: 180), curve: Curves.easeOutBack, - width: 36, - height: 36, + width: widget.size, + height: widget.size, decoration: BoxDecoration( color: active ? pt.accent : pt.bg3, borderRadius: BorderRadius.circular(pt.rSm), @@ -549,11 +564,12 @@ class _SendButtonState extends State<_SendButton> { child: Center( child: Icon( Icons.send_rounded, - size: 16, + size: widget.size * 0.45, color: active ? pt.accentFg : pt.fgDim, ), ), ), + ), ), ); } diff --git a/lib/features/chat/view/chat_header.dart b/lib/features/chat/view/chat_header.dart index 3d668df..4e2d553 100644 --- a/lib/features/chat/view/chat_header.dart +++ b/lib/features/chat/view/chat_header.dart @@ -42,11 +42,13 @@ class _ChatHeader extends ConsumerWidget { children: [ // Back button on mobile if (isMobile) ...[ - GestureDetector( - onTap: () => ref.read(activeRoomIdProvider.notifier).state = null, - child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), + PyrIconBtn( + icon: Icon(Icons.arrow_back_rounded, color: pt.fg), + tooltip: 'Zurück', + onPressed: () => + ref.read(activeRoomIdProvider.notifier).state = null, ), - const SizedBox(width: 12), + const SizedBox(width: 4), ], // Room icon / avatar if (isDm && client != null) ...[ @@ -141,19 +143,27 @@ class _ChatHeader extends ConsumerWidget { onPressed: onTogglePinned, ), ], - PyrIconBtn( - size: 28, - icon: const Icon(Icons.search_rounded), - tooltip: 'Suchen', - onPressed: onOpenSearch, - ), - PyrIconBtn( - size: 28, - icon: const Icon(Icons.group_rounded), - tooltip: 'Mitglieder', - active: membersOpen, - onPressed: onToggleMembers, - ), + if (isMobile) + _ChatOverflowMenu( + pt: pt, + onSearch: onOpenSearch, + onMembers: onToggleMembers, + ) + else ...[ + PyrIconBtn( + size: 28, + icon: const Icon(Icons.search_rounded), + tooltip: 'Suchen', + onPressed: onOpenSearch, + ), + PyrIconBtn( + size: 28, + icon: const Icon(Icons.group_rounded), + tooltip: 'Mitglieder', + active: membersOpen, + onPressed: onToggleMembers, + ), + ], ], ), ], @@ -222,6 +232,20 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> { Profile? _profile; 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; bool _online = false; @@ -291,6 +315,7 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> { @override Widget build(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((_) => _measureActions()); final pt = widget.pt; final expanded = widget.expanded; final size = MediaQuery.sizeOf(context); @@ -361,198 +386,222 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> { ), ), ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - if (isMobile) ...[ - GestureDetector( - onTap: () => - ref.read(activeRoomIdProvider.notifier).state = null, - child: - Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), - ), - const SizedBox(width: 12), - ], - // Klickbarer Profilbereich: Avatar + Name + Details - Expanded( - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: widget.onToggle, - child: Row( - children: [ - // Avatar — skaliert mit (rund, wie im Mockup) - AnimatedContainer( - duration: _animDuration, - curve: _animCurve, - width: avatarSize, - height: avatarSize, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: pt.bg3, - ), - clipBehavior: Clip.antiAlias, - child: avatarUrl != null && client != null - ? MxcImage( - mxcUri: avatarUrl.toString(), - client: client, - fit: BoxFit.cover, - placeholder: _initialBox(initial, pt), - ) - : _initialBox(initial, pt), - ), - AnimatedContainer( - duration: _animDuration, - curve: _animCurve, - width: expanded ? 20 : 10, - ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, + // Profilbereich: gleitet beim Aufklappen unter die oberste Zeile und + // nutzt dann die volle Breite (vorher teilte er sie mit den Symbolen – + // aufgeklappt wurde der Name zu „p…“ gekürzt). + AnimatedPositioned( + duration: _animDuration, + curve: _animCurve, + left: expanded ? 16 : (isMobile ? 54 : 16), + right: expanded ? 16 : _actionsWidth + (isMobile ? 14 : 24), + top: expanded ? 44 : 0, + bottom: 0, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onToggle, + child: Row( + children: [ + // Avatar — skaliert mit (rund, wie im Mockup) + AnimatedContainer( + duration: _animDuration, + curve: _animCurve, + width: avatarSize, + height: avatarSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: pt.bg3, + ), + clipBehavior: Clip.antiAlias, + child: avatarUrl != null && client != null + ? MxcImage( + mxcUri: avatarUrl.toString(), + client: client, + fit: BoxFit.cover, + placeholder: + _initialBox(initial, pt, avatarSize), + ) + : _initialBox(initial, pt, avatarSize), + ), + AnimatedContainer( + duration: _animDuration, + curve: _animCurve, + width: expanded ? 20 : 10, + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Name skaliert mit; Status blendet daneben ein + Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Name skaliert mit; Status blendet daneben ein - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Flexible( - child: AnimatedDefaultTextStyle( - duration: _animDuration, - curve: _animCurve, - style: TextStyle( - color: pt.fg, - fontSize: expanded ? 24 : 15, - fontWeight: expanded - ? FontWeight.w700 - : FontWeight.w600, - ), - child: Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), + Flexible( + child: AnimatedDefaultTextStyle( + duration: _animDuration, + curve: _animCurve, + style: TextStyle( + color: pt.fg, + fontSize: expanded ? 24 : 15, + fontWeight: expanded + ? FontWeight.w700 + : FontWeight.w600, ), - AnimatedOpacity( - opacity: expanded ? 1 : 0, - duration: _animDuration, - child: Padding( - padding: - const EdgeInsets.only(left: 8), - child: Text( - _online ? 'online' : 'offline', - style: TextStyle( - color: _online - ? pt.online - : pt.fgMuted, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ], + ), ), - // Eingeklappt: "zuletzt online vor…", - // aufgeklappt: Handle + Biographie. - AnimatedCrossFade( + AnimatedOpacity( + opacity: expanded ? 1 : 0, duration: _animDuration, - sizeCurve: _animCurve, - crossFadeState: expanded - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - firstChild: client != null - ? _PresenceLine( - userId: _partnerId, - client: client, - pt: pt, - ) - : const SizedBox.shrink(), - secondChild: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - Text( - userId, - style: TextStyle( - color: pt.fgMuted, fontSize: 13), - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: Padding( + padding: + const EdgeInsets.only(left: 8), + child: Text( + _online ? 'online' : 'offline', + style: TextStyle( + color: _online + ? pt.online + : pt.fgMuted, + fontSize: 13, + fontWeight: FontWeight.w500, ), - if (_statusMsg != null && - _statusMsg!.isNotEmpty) ...[ - const SizedBox(height: 8), - Text( - _statusMsg!, - style: TextStyle( - color: pt.fgMuted, - fontSize: 13, - height: 1.35), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ], + ), ), ), ], ), - ), - ], + // Eingeklappt: "zuletzt online vor…", + // aufgeklappt: Handle + Biographie. + AnimatedCrossFade( + duration: _animDuration, + sizeCurve: _animCurve, + crossFadeState: expanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: client != null + ? _PresenceLine( + userId: _partnerId, + client: client, + pt: pt, + ) + : const SizedBox.shrink(), + secondChild: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + userId, + style: TextStyle( + color: pt.fgMuted, fontSize: 13), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (_statusMsg != null && + _statusMsg!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + _statusMsg!, + style: TextStyle( + color: pt.fgMuted, + fontSize: 13, + height: 1.35), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), ), - ), + ], ), + ), + ), + ), + // Zurück-Pfeil und Aktionen: fest in der obersten 52-px-Zeile – + // sie rutschen beim Aufklappen nicht mehr in die Mitte. + 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, ), - const SizedBox(width: 12), - // Aktionen — bleiben immer sichtbar - Row( + ), + ), + Positioned( + right: isMobile ? 6 : 16, + top: 0, + height: 52, + child: KeyedSubtree( + key: _actionsKey, + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - PyrIconBtn( - size: 28, - icon: const Icon(Icons.phone_rounded), - tooltip: 'Voice call', - onPressed: () => _startCall(cam: false), - ), - PyrIconBtn( - size: 28, - icon: const Icon(Icons.videocam_rounded), - tooltip: 'Video call', - onPressed: () => _startCall(cam: true), - ), - if (size.width > 700) - Container( - width: 1, - height: 20, - margin: const EdgeInsets.symmetric(horizontal: 4), - color: pt.border, - ), - if (size.width > 800) - PyrIconBtn( - size: 28, - icon: const Icon(Icons.push_pin_rounded), - tooltip: 'Angepinnte Nachrichten', - active: widget.pinnedOpen, - onPressed: widget.onTogglePinned, - ), - PyrIconBtn( - size: 28, - icon: const Icon(Icons.search_rounded), - tooltip: 'Suchen', - onPressed: widget.onOpenSearch, - ), - PyrIconBtn( - size: 28, - icon: const Icon(Icons.group_rounded), - tooltip: 'Mitglieder', - active: widget.membersOpen, - onPressed: widget.onToggleMembers, - ), - ], + PyrIconBtn( + size: 28, + icon: const Icon(Icons.phone_rounded), + tooltip: 'Voice call', + onPressed: () => _startCall(cam: false), + ), + PyrIconBtn( + size: 28, + icon: const Icon(Icons.videocam_rounded), + tooltip: 'Video call', + onPressed: () => _startCall(cam: true), + ), + if (size.width > 700) + Container( + width: 1, + height: 20, + margin: const EdgeInsets.symmetric(horizontal: 4), + color: pt.border, + ), + if (size.width > 800) + PyrIconBtn( + size: 28, + icon: const Icon(Icons.push_pin_rounded), + tooltip: 'Angepinnte Nachrichten', + active: widget.pinnedOpen, + onPressed: widget.onTogglePinned, + ), + if (isMobile) + _ChatOverflowMenu( + pt: pt, + onSearch: widget.onOpenSearch, + onMembers: widget.onToggleMembers, + ) + else ...[ + PyrIconBtn( + size: 28, + icon: const Icon(Icons.search_rounded), + tooltip: 'Suchen', + onPressed: widget.onOpenSearch, + ), + PyrIconBtn( + size: 28, + icon: const Icon(Icons.group_rounded), + tooltip: 'Mitglieder', + active: widget.membersOpen, + onPressed: widget.onToggleMembers, ), ], + ], + ), ), ), ], @@ -560,15 +609,25 @@ class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> { ); } - Widget _initialBox(String initial, PyramidTheme pt) => Center( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Padding( - padding: const EdgeInsets.all(6), - child: Text( - initial, - style: TextStyle( - color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700), + // Kürzel exakt mittig: Schrift wächst mit dem Avatar mit (vorher feste + // 28 px in FittedBox mit Innenabstand – wirkte aufgeklappt verschoben). + Widget _initialBox(String initial, PyramidTheme pt, double avatarSize) => + Center( + 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( + initial, + textHeightBehavior: const TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, ), ), ), @@ -733,3 +792,48 @@ class _PresenceLineState extends State<_PresenceLine> { 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 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( + 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), + ], + ); + } +} diff --git a/lib/widgets/hover_region.dart b/lib/widgets/hover_region.dart index 23d0550..0a6bfc3 100644 --- a/lib/widgets/hover_region.dart +++ b/lib/widgets/hover_region.dart @@ -1,4 +1,9 @@ +import 'dart:math' as math; + 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 class HoverRegion extends StatefulWidget { @@ -29,7 +34,10 @@ class _HoverRegionState extends State { } } -// 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 { final Widget icon; final VoidCallback? onPressed; @@ -39,6 +47,9 @@ class PyrIconBtn extends StatefulWidget { final Color? dangerColor; final double size; + /// Symbolgröße; ohne Angabe 16 (PC) bzw. 22 (Handy-Layout). + final double? iconSize; + const PyrIconBtn({ super.key, required this.icon, @@ -48,6 +59,7 @@ class PyrIconBtn extends StatefulWidget { this.activeColor, this.dangerColor, this.size = 32, + this.iconSize, }); @override @@ -59,20 +71,16 @@ class _PyrIconBtnState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - 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 pt = PyramidTheme.of(context); + final mobile = isMobileLayout(MediaQuery.sizeOf(context)); final color = widget.active - ? (widget.activeColor ?? const Color(0xFFF5A614)) - : _hovered ? fg : fgMuted; + ? (widget.activeColor ?? pt.accent) + : _hovered ? pt.fg : pt.fgMuted; - final platform = Theme.of(context).platform; - final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS; - // On mobile, use a slightly larger touch target, but respect the requested size - final touchSize = isMobile ? (widget.size + 8).clamp(32.0, 48.0) : widget.size; + final boxSize = mobile ? math.max(widget.size, 40.0) : widget.size; + final touchSize = mobile ? math.max(boxSize, 44.0) : boxSize; + final iconSize = widget.iconSize ?? (mobile ? 22.0 : 16.0); final btn = MouseRegion( cursor: SystemMouseCursors.click, @@ -80,23 +88,26 @@ class _PyrIconBtnState extends State { onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onPressed, - child: SizedBox( - width: touchSize, - height: touchSize, - child: Center( - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOutCubic, - width: widget.size, - height: widget.size, - decoration: BoxDecoration( - color: _hovered ? bgHover : Colors.transparent, - borderRadius: BorderRadius.circular(7), - ), - child: Center( - child: IconTheme( - data: IconThemeData(color: color, size: 16), - child: widget.icon, + child: SpringPress( + enabled: widget.onPressed != null, + child: SizedBox( + width: touchSize, + height: touchSize, + child: Center( + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + width: boxSize, + height: boxSize, + decoration: BoxDecoration( + color: _hovered ? pt.bgHover : Colors.transparent, + borderRadius: BorderRadius.circular(mobile ? boxSize / 2 : 7), + ), + child: Center( + child: IconTheme( + data: IconThemeData(color: color, size: iconSize), + child: widget.icon, + ), ), ), ), diff --git a/lib/widgets/spring_press.dart b/lib/widgets/spring_press.dart new file mode 100644 index 0000000..e65d4c7 --- /dev/null +++ b/lib/widgets/spring_press.dart @@ -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 createState() => _SpringPressState(); +} + +class _SpringPressState extends State { + 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()); +}