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:
co-authored by
Claude Opus 5.5
parent
36875ef9cd
commit
382747cdb1
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user