feat: federnde Übergänge und Animationen, Entwürfe je Raum
- 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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1d0ccfde8b
commit
ae6f651f4c
@@ -60,6 +60,9 @@ void main() {
|
|||||||
|
|
||||||
// ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ──
|
// ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ──
|
||||||
await tester.tap(find.text(_otherUser).first);
|
await tester.tap(find.text(_otherUser).first);
|
||||||
|
// Chat gleitet federnd über die Liste.
|
||||||
|
await tester.pump(const Duration(milliseconds: 90));
|
||||||
|
await _shot(tester, '01b_chat_gleitet_herein');
|
||||||
await _pumpFor(tester, const Duration(seconds: 6));
|
await _pumpFor(tester, const Duration(seconds: 6));
|
||||||
await _scrollToNewest(tester);
|
await _scrollToNewest(tester);
|
||||||
await _shot(tester, '02_chat');
|
await _shot(tester, '02_chat');
|
||||||
@@ -96,6 +99,24 @@ void main() {
|
|||||||
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||||||
await _pumpFor(tester, const Duration(seconds: 1));
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
|
||||||
|
// Antworten → Antwort-Leiste federt auf. (Wischen zum Antworten gibt
|
||||||
|
// es nur auf echten Handys; hier über Lange-Drücken → Antworten.)
|
||||||
|
// Neben die eigene Sprechblase drücken (nicht in den Text – dort
|
||||||
|
// greift am PC die Textauswahl).
|
||||||
|
final ownMsg = tester.getCenter(find.text('Danke, sieht gut aus!').first);
|
||||||
|
await tester.longPressAt(Offset(size.width * 0.15, ownMsg.dy));
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 500));
|
||||||
|
await tester.tap(find.byIcon(Icons.reply_rounded).first);
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
await _shot(tester, '02g_antwort_leiste_federt');
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 800));
|
||||||
|
await _shot(tester, '02h_antwort_leiste');
|
||||||
|
final closeReply = find.byIcon(Icons.close_rounded);
|
||||||
|
if (closeReply.evaluate().isNotEmpty) {
|
||||||
|
await tester.tap(closeReply.first);
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 600));
|
||||||
|
}
|
||||||
|
|
||||||
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');
|
||||||
@@ -210,6 +231,14 @@ void main() {
|
|||||||
await _shot(tester, '10_einstellungen_text_chat');
|
await _shot(tester, '10_einstellungen_text_chat');
|
||||||
container.read(activeModalProvider.notifier).state = ModalKind.none;
|
container.read(activeModalProvider.notifier).state = ModalKind.none;
|
||||||
await _pumpFor(tester, const Duration(seconds: 1));
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
|
||||||
|
// ── Zurück zur Chatliste: Chat gleitet hinaus, Liste bleibt erhalten ──
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await tester.pump(const Duration(milliseconds: 90));
|
||||||
|
await _shot(tester, '11_zurueck_gleitet_hinaus');
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
expect(find.text(_groupName), findsWidgets);
|
||||||
|
await _shot(tester, '12_liste_nach_zurueck');
|
||||||
},
|
},
|
||||||
skip: profile == null,
|
skip: profile == null,
|
||||||
timeout: const Timeout(Duration(minutes: 6)),
|
timeout: const Timeout(Duration(minutes: 6)),
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ class ChatComposer extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ChatComposerState extends ConsumerState<ChatComposer> {
|
class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||||
|
/// Angefangene Nachrichten je Raum (nur im Speicher): Beim Raumwechsel
|
||||||
|
/// bleibt der Text beim richtigen Raum, statt im nächsten Chat zu landen.
|
||||||
|
static final _drafts = <String, String>{};
|
||||||
|
|
||||||
final _ctrl = TextEditingController();
|
final _ctrl = TextEditingController();
|
||||||
late final FocusNode _focus;
|
late final FocusNode _focus;
|
||||||
bool _canSend = false;
|
bool _canSend = false;
|
||||||
@@ -47,6 +51,12 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_focus = FocusNode(onKeyEvent: _handleKeyEvent);
|
_focus = FocusNode(onKeyEvent: _handleKeyEvent);
|
||||||
|
// Entwurf vor dem Listener setzen – sonst ginge sofort „tippt…“ raus.
|
||||||
|
final draft = _drafts.remove(widget.room.id);
|
||||||
|
if (draft != null) {
|
||||||
|
_ctrl.text = draft;
|
||||||
|
_canSend = draft.trim().isNotEmpty;
|
||||||
|
}
|
||||||
_ctrl.addListener(_onTextChanged);
|
_ctrl.addListener(_onTextChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +131,8 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
final text = _ctrl.text;
|
||||||
|
if (text.trim().isNotEmpty) _drafts[widget.room.id] = text;
|
||||||
_typingTimer?.cancel();
|
_typingTimer?.cancel();
|
||||||
if (_isTyping) widget.room.setTyping(false).catchError((_) {});
|
if (_isTyping) widget.room.setTyping(false).catchError((_) {});
|
||||||
_gifOverlay?.remove();
|
_gifOverlay?.remove();
|
||||||
@@ -315,12 +327,28 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (widget.replyTo != null)
|
// Antwort-Leiste federt auf und klappt beim Schließen weich zu.
|
||||||
_ReplyBar(
|
AnimatedSwitcher(
|
||||||
|
duration: motionDuration(context, 300),
|
||||||
|
reverseDuration: motionDuration(context, 160),
|
||||||
|
switchInCurve: Curves.easeOutBack,
|
||||||
|
switchOutCurve: Curves.easeIn,
|
||||||
|
transitionBuilder: (child, anim) => SizeTransition(
|
||||||
|
sizeFactor: anim,
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: FadeTransition(opacity: anim, child: child),
|
||||||
|
),
|
||||||
|
child: widget.replyTo != null
|
||||||
|
? KeyedSubtree(
|
||||||
|
key: ValueKey('reply:${widget.replyTo!.eventId}'),
|
||||||
|
child: _ReplyBar(
|
||||||
event: widget.replyTo!,
|
event: widget.replyTo!,
|
||||||
onClear: widget.onClearReply ?? () {},
|
onClear: widget.onClearReply ?? () {},
|
||||||
pt: pt,
|
pt: pt,
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox(key: ValueKey('reply:none'), width: double.infinity),
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
// Bottom inset matches the profile block in the rooms list (8px) so
|
// Bottom inset matches the profile block in the rooms list (8px) so
|
||||||
// the composer sits flush with it.
|
// the composer sits flush with it.
|
||||||
@@ -574,6 +602,13 @@ class _SendButtonState extends State<_SendButton> {
|
|||||||
: Matrix4.identity(),
|
: Matrix4.identity(),
|
||||||
transformAlignment: Alignment.center,
|
transformAlignment: Alignment.center,
|
||||||
child: Center(
|
child: Center(
|
||||||
|
// Sobald gesendet werden kann, ploppt der Pfeil federnd auf.
|
||||||
|
child: AnimatedScale(
|
||||||
|
scale: active ? 1 : 0.8,
|
||||||
|
duration: active
|
||||||
|
? motionDuration(context, 520)
|
||||||
|
: const Duration(milliseconds: 140),
|
||||||
|
curve: active ? Curves.elasticOut : Curves.easeOut,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.send_rounded,
|
Icons.send_rounded,
|
||||||
size: widget.size * 0.45,
|
size: widget.size * 0.45,
|
||||||
@@ -583,6 +618,7 @@ class _SendButtonState extends State<_SendButton> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -627,9 +663,20 @@ class _ReplyBar extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Handy: größeres X mit fingergerechter Trefferfläche.
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: onClear,
|
onTap: onClear,
|
||||||
child: Icon(Icons.close_rounded, size: 16, color: pt.fgDim),
|
child: SpringPress(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: Icon(
|
||||||
|
Icons.close_rounded,
|
||||||
|
size: isMobileLayout(MediaQuery.sizeOf(context)) ? 22 : 16,
|
||||||
|
color: pt.fgDim,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:pyramid/core/app_state.dart';
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:pyramid/core/appearance.dart' show isMobileLayout;
|
||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
import 'package:pyramid/core/settings_prefs.dart';
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
@@ -20,6 +21,7 @@ import 'package:pyramid/features/chat/message_group.dart';
|
|||||||
import 'package:pyramid/features/chat/pinned_messages_panel.dart';
|
import 'package:pyramid/features/chat/pinned_messages_panel.dart';
|
||||||
import 'package:pyramid/widgets/hover_region.dart';
|
import 'package:pyramid/widgets/hover_region.dart';
|
||||||
import 'package:pyramid/widgets/measure_size.dart';
|
import 'package:pyramid/widgets/measure_size.dart';
|
||||||
|
import 'package:pyramid/widgets/spring_press.dart';
|
||||||
import 'package:pyramid/core/read_markers.dart';
|
import 'package:pyramid/core/read_markers.dart';
|
||||||
import 'package:pyramid/widgets/mxc_image.dart';
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
import 'package:pyramid/features/chat/media_player.dart';
|
import 'package:pyramid/features/chat/media_player.dart';
|
||||||
import 'package:pyramid/utils/gif_favorite_service.dart';
|
import 'package:pyramid/utils/gif_favorite_service.dart';
|
||||||
|
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
|
||||||
|
|
||||||
/// Overlay viewer for images, GIFs and videos.
|
/// Overlay viewer for images, GIFs and videos.
|
||||||
///
|
///
|
||||||
@@ -50,9 +51,19 @@ class MediaViewer extends StatefulWidget {
|
|||||||
// Den Hintergrund zeichnet der Viewer selbst (damit er beim Wischen
|
// Den Hintergrund zeichnet der Viewer selbst (damit er beim Wischen
|
||||||
// ausblenden kann).
|
// ausblenden kann).
|
||||||
barrierColor: Colors.transparent,
|
barrierColor: Colors.transparent,
|
||||||
transitionDuration: const Duration(milliseconds: 180),
|
transitionDuration: motionDuration(context, 300),
|
||||||
transitionBuilder: (context, animation, secondaryAnimation, child) =>
|
transitionBuilder: (context, animation, secondaryAnimation, child) =>
|
||||||
FadeTransition(opacity: animation, child: child),
|
FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: ScaleTransition(
|
||||||
|
scale: Tween(begin: 0.9, end: 1.0).animate(CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOutBack,
|
||||||
|
reverseCurve: Curves.easeIn,
|
||||||
|
)),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => MediaViewer(
|
pageBuilder: (context, animation, secondaryAnimation) => MediaViewer(
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
filename: filename,
|
filename: filename,
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
part of '../message_group.dart';
|
part of '../message_group.dart';
|
||||||
|
|
||||||
|
/// Bereits federnd eingeblendete Nachrichten (begrenzt, älteste fallen raus).
|
||||||
|
final _poppedMessageIds = <String>{};
|
||||||
|
|
||||||
|
void _rememberPopped(String id) {
|
||||||
|
_poppedMessageIds.add(id);
|
||||||
|
if (_poppedMessageIds.length > 300) {
|
||||||
|
_poppedMessageIds.remove(_poppedMessageIds.first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _formatMessageTime(DateTime t) {
|
String _formatMessageTime(DateTime t) {
|
||||||
final h = t.hour.toString().padLeft(2, '0');
|
final h = t.hour.toString().padLeft(2, '0');
|
||||||
final m = t.minute.toString().padLeft(2, '0');
|
final m = t.minute.toString().padLeft(2, '0');
|
||||||
@@ -28,6 +38,10 @@ class MessageGroup extends StatefulWidget {
|
|||||||
final GlobalKey? jumpKey;
|
final GlobalKey? jumpKey;
|
||||||
final String? highlightEventId;
|
final String? highlightEventId;
|
||||||
final bool highlightOn;
|
final bool highlightOn;
|
||||||
|
/// Nachrichten, die nach diesem Zeitpunkt eintreffen (oder gerade
|
||||||
|
/// gesendet werden), ploppen beim ersten Erscheinen federnd auf. null = aus
|
||||||
|
/// (z. B. solange der Chat gerade erst geöffnet wird).
|
||||||
|
final DateTime? popInAfter;
|
||||||
|
|
||||||
const MessageGroup({
|
const MessageGroup({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -45,6 +59,7 @@ class MessageGroup extends StatefulWidget {
|
|||||||
this.jumpKey,
|
this.jumpKey,
|
||||||
this.highlightEventId,
|
this.highlightEventId,
|
||||||
this.highlightOn = false,
|
this.highlightOn = false,
|
||||||
|
this.popInAfter,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -301,15 +316,39 @@ class _MessageGroupState extends State<MessageGroup> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (_isMobile) return row;
|
if (_isMobile) return _popIn(event, row);
|
||||||
|
|
||||||
// Desktop: per-row hover highlight + actions.
|
// Desktop: per-row hover highlight + actions.
|
||||||
return MouseRegion(
|
return _popIn(event, MouseRegion(
|
||||||
onEnter: (_) => setState(() => _hoveredEventId = event.eventId),
|
onEnter: (_) => setState(() => _hoveredEventId = event.eventId),
|
||||||
onExit: (_) => setState(() {
|
onExit: (_) => setState(() {
|
||||||
if (_hoveredEventId == event.eventId) _hoveredEventId = null;
|
if (_hoveredEventId == event.eventId) _hoveredEventId = null;
|
||||||
}),
|
}),
|
||||||
child: row,
|
child: row,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neu angekommene oder gerade gesendete Nachricht: federnd einblenden.
|
||||||
|
// Der Schlüssel (Transaktions-ID bleibt beim Senden gleich) hält den
|
||||||
|
// Zustand der Zeile, die Merkliste verhindert ein zweites Abspielen, falls
|
||||||
|
// die Gruppe neu aufgebaut wird (z. B. lokale Kopie → Server-Event).
|
||||||
|
Widget _popIn(Event event, Widget row) {
|
||||||
|
final id = event.transactionId ?? event.eventId;
|
||||||
|
final after = widget.popInAfter;
|
||||||
|
final fresh = after != null &&
|
||||||
|
!_poppedMessageIds.contains(id) &&
|
||||||
|
(event.status.isSending ||
|
||||||
|
(event.originServerTs.isAfter(after) &&
|
||||||
|
DateTime.now().difference(event.originServerTs) <
|
||||||
|
const Duration(minutes: 2)));
|
||||||
|
if (fresh) _rememberPopped(id);
|
||||||
|
return PopIn(
|
||||||
|
key: ValueKey('row:$id'),
|
||||||
|
play: fresh,
|
||||||
|
alignment: event.senderId == widget.currentUserId
|
||||||
|
? Alignment.bottomRight
|
||||||
|
: Alignment.bottomLeft,
|
||||||
|
child: row,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,15 @@ class _ReactionsRow extends StatelessWidget {
|
|||||||
|
|
||||||
// Group by emoji key → list of senders
|
// Group by emoji key → list of senders
|
||||||
final Map<String, List<String>> grouped = {};
|
final Map<String, List<String>> grouped = {};
|
||||||
|
final newest = <String, DateTime>{};
|
||||||
for (final r in reactionEvents) {
|
for (final r in reactionEvents) {
|
||||||
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
|
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
|
||||||
if (key == null || key.isEmpty) continue;
|
if (key == null || key.isEmpty) continue;
|
||||||
(grouped[key] ??= []).add(r.senderId);
|
(grouped[key] ??= []).add(r.senderId);
|
||||||
|
final ts = r.originServerTs;
|
||||||
|
if (newest[key] == null || ts.isAfter(newest[key]!)) newest[key] = ts;
|
||||||
}
|
}
|
||||||
|
final now = DateTime.now();
|
||||||
if (grouped.isEmpty) return const SizedBox.shrink();
|
if (grouped.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -37,6 +41,9 @@ class _ReactionsRow extends StatelessWidget {
|
|||||||
children: grouped.entries.map((e) {
|
children: grouped.entries.map((e) {
|
||||||
final myReaction = e.value.contains(currentUserId);
|
final myReaction = e.value.contains(currentUserId);
|
||||||
return _ReactionChip(
|
return _ReactionChip(
|
||||||
|
key: ValueKey(e.key),
|
||||||
|
// Gerade erst vergeben → federnd aufploppen.
|
||||||
|
fresh: now.difference(newest[e.key]!) < const Duration(seconds: 4),
|
||||||
emoji: e.key,
|
emoji: e.key,
|
||||||
count: e.value.length,
|
count: e.value.length,
|
||||||
mine: myReaction,
|
mine: myReaction,
|
||||||
@@ -69,8 +76,11 @@ class _ReactionChip extends StatefulWidget {
|
|||||||
final bool mine;
|
final bool mine;
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
final bool fresh;
|
||||||
|
|
||||||
const _ReactionChip({
|
const _ReactionChip({
|
||||||
|
super.key,
|
||||||
|
this.fresh = false,
|
||||||
required this.emoji,
|
required this.emoji,
|
||||||
required this.count,
|
required this.count,
|
||||||
required this.mine,
|
required this.mine,
|
||||||
@@ -88,12 +98,22 @@ class _ReactionChipState extends State<_ReactionChip> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pt = widget.pt;
|
final pt = widget.pt;
|
||||||
return MouseRegion(
|
return PopIn(
|
||||||
|
play: widget.fresh,
|
||||||
|
fromScale: 0.3,
|
||||||
|
offsetY: 0,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
milliseconds: 520,
|
||||||
|
child: SpringBump(
|
||||||
|
value: widget.count,
|
||||||
|
child: MouseRegion(
|
||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
onEnter: (_) => setState(() => _hovered = true),
|
onEnter: (_) => setState(() => _hovered = true),
|
||||||
onExit: (_) => setState(() => _hovered = false),
|
onExit: (_) => setState(() => _hovered = false),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: widget.onTap,
|
onTap: widget.onTap,
|
||||||
|
child: SpringPress(
|
||||||
|
pressedScale: 0.85,
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 120),
|
duration: const Duration(milliseconds: 120),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||||
@@ -123,6 +143,9 @@ class _ReactionChipState extends State<_ReactionChip> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ class _MessageList extends StatelessWidget {
|
|||||||
/// frei, damit die neueste Nachricht ganz über die Leiste scrollen kann.
|
/// frei, damit die neueste Nachricht ganz über die Leiste scrollen kann.
|
||||||
final double bottomInset;
|
final double bottomInset;
|
||||||
|
|
||||||
|
/// Siehe MessageGroup.popInAfter.
|
||||||
|
final DateTime? popInAfter;
|
||||||
|
|
||||||
const _MessageList({
|
const _MessageList({
|
||||||
required this.timeline,
|
required this.timeline,
|
||||||
required this.scrollCtrl,
|
required this.scrollCtrl,
|
||||||
@@ -40,6 +43,7 @@ class _MessageList extends StatelessWidget {
|
|||||||
this.highlightEventId,
|
this.highlightEventId,
|
||||||
this.highlightOn = false,
|
this.highlightOn = false,
|
||||||
this.bottomInset = 0,
|
this.bottomInset = 0,
|
||||||
|
this.popInAfter,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -292,6 +296,7 @@ class _MessageList extends StatelessWidget {
|
|||||||
jumpKey: jumpKey,
|
jumpKey: jumpKey,
|
||||||
highlightEventId: highlightEventId,
|
highlightEventId: highlightEventId,
|
||||||
highlightOn: highlightOn,
|
highlightOn: highlightOn,
|
||||||
|
popInAfter: popInAfter,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
// Neuaufbau erneut eine Lesebestätigung schickt, und markiert nach dem
|
// Neuaufbau erneut eine Lesebestätigung schickt, und markiert nach dem
|
||||||
// Zurückkehren in die App, ab wo „Neue Nachrichten“ beginnen.
|
// Zurückkehren in die App, ab wo „Neue Nachrichten“ beginnen.
|
||||||
String? _lastMarkedEventId;
|
String? _lastMarkedEventId;
|
||||||
|
// Was danach eintrifft, ploppt federnd auf (beim Öffnen gesetzt, damit die
|
||||||
|
// schon vorhandenen Nachrichten ruhig erscheinen).
|
||||||
|
DateTime? _popInAfter;
|
||||||
|
|
||||||
// Jump-to-message (from search): scroll to + briefly highlight a target event.
|
// Jump-to-message (from search): scroll to + briefly highlight a target event.
|
||||||
final _jumpKey = GlobalKey();
|
final _jumpKey = GlobalKey();
|
||||||
@@ -115,6 +118,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
|
|
||||||
bool get _inSelectionMode => _selectedIds.isNotEmpty;
|
bool get _inSelectionMode => _selectedIds.isNotEmpty;
|
||||||
|
|
||||||
|
double get _scrollBtnSize =>
|
||||||
|
isMobileLayout(MediaQuery.sizeOf(context)) ? 44 : 36;
|
||||||
|
|
||||||
void _toggleSelect(String eventId) {
|
void _toggleSelect(String eventId) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (_selectedIds.contains(eventId)) {
|
if (_selectedIds.contains(eventId)) {
|
||||||
@@ -142,6 +148,11 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
void didUpdateWidget(ChatView oldWidget) {
|
void didUpdateWidget(ChatView oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.roomId != widget.roomId) {
|
if (oldWidget.roomId != widget.roomId) {
|
||||||
|
// Antwort/Auswahl gehören zum alten Raum – sonst ginge eine Antwort
|
||||||
|
// auf eine Nachricht aus Raum A in Raum B raus.
|
||||||
|
_replyTo = null;
|
||||||
|
_selectedIds.clear();
|
||||||
|
_showScrollDown = false;
|
||||||
_profileExpanded = false;
|
_profileExpanded = false;
|
||||||
_sessionFullyReadId = null;
|
_sessionFullyReadId = null;
|
||||||
_sessionHasUnread = false;
|
_sessionHasUnread = false;
|
||||||
@@ -152,6 +163,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
_unreadFading = false;
|
_unreadFading = false;
|
||||||
_unreadFadeTimer?.cancel();
|
_unreadFadeTimer?.cancel();
|
||||||
_lastMarkedEventId = null;
|
_lastMarkedEventId = null;
|
||||||
|
_popInAfter = null;
|
||||||
_jumpEventId = null;
|
_jumpEventId = null;
|
||||||
_highlightEventId = null;
|
_highlightEventId = null;
|
||||||
_highlightOn = false;
|
_highlightOn = false;
|
||||||
@@ -202,6 +214,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_sessionFullyReadId = newFullyReadId;
|
_sessionFullyReadId = newFullyReadId;
|
||||||
_sessionHasUnread = newHasUnread;
|
_sessionHasUnread = newHasUnread;
|
||||||
|
_popInAfter = events.firstOrNull?.originServerTs ?? DateTime.now();
|
||||||
});
|
});
|
||||||
if (newHasUnread) {
|
if (newHasUnread) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline));
|
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline));
|
||||||
@@ -499,28 +512,35 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
highlightEventId: _highlightEventId,
|
highlightEventId: _highlightEventId,
|
||||||
highlightOn: _highlightOn,
|
highlightOn: _highlightOn,
|
||||||
bottomInset: room != null ? _composerHeight : 0,
|
bottomInset: room != null ? _composerHeight : 0,
|
||||||
|
popInAfter: _popInAfter,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (_showScrollDown)
|
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: (room != null ? _composerHeight : 0) + 12,
|
bottom: (room != null ? _composerHeight : 0) + 12,
|
||||||
child: AnimatedOpacity(
|
child: IgnorePointer(
|
||||||
opacity: _showScrollDown ? 1 : 0,
|
ignoring: !_showScrollDown,
|
||||||
duration: const Duration(milliseconds: 150),
|
child: AnimatedScale(
|
||||||
|
scale: _showScrollDown ? 1 : 0,
|
||||||
|
duration: motionDuration(
|
||||||
|
context, _showScrollDown ? 520 : 180),
|
||||||
|
curve: _showScrollDown
|
||||||
|
? Curves.elasticOut
|
||||||
|
: Curves.easeInBack,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => _scrollCtrl.animateTo(
|
onTap: () => _scrollCtrl.animateTo(
|
||||||
0,
|
0,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
),
|
),
|
||||||
|
child: SpringPress(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 36,
|
width: _scrollBtnSize,
|
||||||
height: 36,
|
height: _scrollBtnSize,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: pt.bg0,
|
color: pt.bg0,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(_scrollBtnSize / 2),
|
||||||
border: Border.all(color: pt.border),
|
border: Border.all(color: pt.border),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -530,7 +550,10 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: pt.fgMuted),
|
child: Icon(Icons.keyboard_arrow_down_rounded,
|
||||||
|
size: _scrollBtnSize * 0.56, color: pt.fgMuted),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -561,6 +584,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
children: [
|
children: [
|
||||||
_TypingIndicator(room: room, pt: pt),
|
_TypingIndicator(room: room, pt: pt),
|
||||||
ChatComposer(
|
ChatComposer(
|
||||||
|
// Je Raum ein eigenes Eingabefeld
|
||||||
|
// (Text, „tippt…“, Entwurf).
|
||||||
|
key: ValueKey('composer:${room.id}'),
|
||||||
room: room,
|
room: room,
|
||||||
replyTo: _replyTo,
|
replyTo: _replyTo,
|
||||||
onClearReply: () =>
|
onClearReply: () =>
|
||||||
|
|||||||
@@ -95,10 +95,18 @@ class _MobileRoomTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (unread > 0) ...[
|
// Immer im Baum, damit der Zähler beim Erscheinen und
|
||||||
const SizedBox(width: 8),
|
// Hochzählen federnd aufploppen kann.
|
||||||
_UnreadBadge(count: unread, isMention: mention, pt: pt),
|
SpringBump(
|
||||||
],
|
value: unread,
|
||||||
|
child: unread > 0
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: _UnreadBadge(
|
||||||
|
count: unread, isMention: mention, pt: pt),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import 'package:pyramid/widgets/create_join_dialog.dart';
|
|||||||
import 'package:pyramid/widgets/hover_region.dart';
|
import 'package:pyramid/widgets/hover_region.dart';
|
||||||
import 'package:pyramid/widgets/mxc_image.dart';
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
import 'package:pyramid/widgets/new_dm_dialog.dart';
|
import 'package:pyramid/widgets/new_dm_dialog.dart';
|
||||||
|
import 'package:pyramid/widgets/spring_press.dart' show SpringBump;
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+31
-10
@@ -17,6 +17,8 @@ import 'package:pyramid/features/chat/chat_view.dart';
|
|||||||
import 'package:pyramid/features/members/members_panel.dart';
|
import 'package:pyramid/features/members/members_panel.dart';
|
||||||
import 'package:pyramid/features/rooms/rooms_panel.dart';
|
import 'package:pyramid/features/rooms/rooms_panel.dart';
|
||||||
import 'package:pyramid/features/spaces/spaces_rail.dart';
|
import 'package:pyramid/features/spaces/spaces_rail.dart';
|
||||||
|
import 'package:pyramid/layout/mobile_page_stack.dart';
|
||||||
|
import 'package:pyramid/widgets/spring_press.dart' show motionDuration;
|
||||||
import 'package:pyramid/widgets/search_modal.dart';
|
import 'package:pyramid/widgets/search_modal.dart';
|
||||||
import 'package:pyramid/widgets/settings_modal.dart';
|
import 'package:pyramid/widgets/settings_modal.dart';
|
||||||
import 'package:pyramid/widgets/share_target_dialog.dart';
|
import 'package:pyramid/widgets/share_target_dialog.dart';
|
||||||
@@ -335,14 +337,18 @@ class _MobileShell extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final voip = ref.watch(callSignalingProvider);
|
final voip = ref.watch(callSignalingProvider);
|
||||||
|
|
||||||
Widget body;
|
// Die Chatliste bleibt aufgebaut; Chat bzw. Sprachkanal gleiten federnd
|
||||||
if (viewMode == ViewMode.voice) {
|
// darüber (Bernds Wunsch 2026-10-07: mehr Übergänge/Elastizität).
|
||||||
body = const VoiceChannelView();
|
final Widget? front = viewMode == ViewMode.voice
|
||||||
} else if (activeRoomId != null) {
|
? const VoiceChannelView()
|
||||||
body = ChatView(roomId: activeRoomId!);
|
: activeRoomId != null
|
||||||
} else {
|
? ChatView(roomId: activeRoomId!)
|
||||||
body = const RoomsPanel();
|
: null;
|
||||||
}
|
final body = MobilePageStack(
|
||||||
|
base: const RoomsPanel(),
|
||||||
|
front: front,
|
||||||
|
background: pt.bg1,
|
||||||
|
);
|
||||||
|
|
||||||
// Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
|
// Symbole der Statusleiste passend zum Theme (hell auf dunkel und umgekehrt).
|
||||||
final overlayStyle = (pt.isDark
|
final overlayStyle = (pt.isDark
|
||||||
@@ -387,7 +393,12 @@ class _MobileShell extends ConsumerWidget {
|
|||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => ref.read(membersPanelOpenProvider.notifier).state = false,
|
onTap: () => ref.read(membersPanelOpenProvider.notifier).state = false,
|
||||||
child: Container(color: Colors.black.withAlpha(110)),
|
child: TweenAnimationBuilder<double>(
|
||||||
|
tween: Tween(begin: 0, end: 1),
|
||||||
|
duration: motionDuration(context, 220),
|
||||||
|
builder: (_, v, _) =>
|
||||||
|
ColoredBox(color: Colors.black.withAlpha((110 * v).round())),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -396,10 +407,20 @@ class _MobileShell extends ConsumerWidget {
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: Builder(builder: (ctx) {
|
child: Builder(builder: (ctx) {
|
||||||
final w = MediaQuery.sizeOf(ctx).width;
|
final w = MediaQuery.sizeOf(ctx).width;
|
||||||
return MembersPanel(
|
// Gleitet von rechts herein.
|
||||||
|
return TweenAnimationBuilder<double>(
|
||||||
|
tween: Tween(begin: 1, end: 0),
|
||||||
|
duration: motionDuration(ctx, 340),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
builder: (_, v, child) => FractionalTranslation(
|
||||||
|
translation: Offset(v, 0),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
child: MembersPanel(
|
||||||
roomId: activeRoomId!,
|
roomId: activeRoomId!,
|
||||||
width: (w * 0.82).clamp(240.0, 320.0),
|
width: (w * 0.82).clamp(240.0, 320.0),
|
||||||
onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false,
|
onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,3 +69,138 @@ Duration motionDuration(BuildContext context, int milliseconds) {
|
|||||||
final motion = PyramidTheme.of(context).motion;
|
final motion = PyramidTheme.of(context).motion;
|
||||||
return Duration(milliseconds: (milliseconds * motion).round());
|
return Duration(milliseconds: (milliseconds * motion).round());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Einmaliges federndes Einblenden (z. B. eine gerade angekommene Nachricht):
|
||||||
|
/// kommt etwas kleiner und tiefer herein und schwingt leicht über.
|
||||||
|
///
|
||||||
|
/// Ob gespielt wird, entscheidet sich nur beim ersten Aufbau ([play]); danach
|
||||||
|
/// bleibt das Kind unverändert, auch wenn [play] sich ändert. Mit
|
||||||
|
/// „Animationen: 0 %“ wird nie gespielt.
|
||||||
|
class PopIn extends StatefulWidget {
|
||||||
|
const PopIn({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.play = true,
|
||||||
|
this.alignment = Alignment.bottomCenter,
|
||||||
|
this.offsetY = 18,
|
||||||
|
this.fromScale = 0.86,
|
||||||
|
this.milliseconds = 460,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final bool play;
|
||||||
|
final Alignment alignment;
|
||||||
|
final double offsetY;
|
||||||
|
final double fromScale;
|
||||||
|
final int milliseconds;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PopIn> createState() => _PopInState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PopInState extends State<PopIn> with SingleTickerProviderStateMixin {
|
||||||
|
AnimationController? _ctrl;
|
||||||
|
bool _decided = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (_decided) return;
|
||||||
|
_decided = true;
|
||||||
|
final motion = PyramidTheme.of(context).motion;
|
||||||
|
if (!widget.play || motion < 0.05) return;
|
||||||
|
_ctrl = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: Duration(milliseconds: (widget.milliseconds * motion).round()),
|
||||||
|
)..forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final ctrl = _ctrl;
|
||||||
|
if (ctrl == null) return widget.child;
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: ctrl,
|
||||||
|
child: widget.child,
|
||||||
|
builder: (context, child) {
|
||||||
|
final t = ctrl.value;
|
||||||
|
final move = Curves.easeOutBack.transform(t);
|
||||||
|
final fade = Curves.easeOut.transform((t / 0.4).clamp(0.0, 1.0));
|
||||||
|
return Opacity(
|
||||||
|
opacity: fade,
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(0, (1 - move) * widget.offsetY),
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: widget.fromScale + (1 - widget.fromScale) * move,
|
||||||
|
alignment: widget.alignment,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Federndes „Aufploppen“, sobald [value] steigt (z. B. Ungelesen-Zähler,
|
||||||
|
/// Anzahl einer Reaktion). Beim ersten Aufbau passiert nichts – so ploppt
|
||||||
|
/// beim Scrollen durch eine Liste nichts grundlos auf.
|
||||||
|
class SpringBump extends StatefulWidget {
|
||||||
|
const SpringBump({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.child,
|
||||||
|
this.alignment = Alignment.center,
|
||||||
|
});
|
||||||
|
|
||||||
|
final num value;
|
||||||
|
final Widget child;
|
||||||
|
final Alignment alignment;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SpringBump> createState() => _SpringBumpState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpringBumpState extends State<SpringBump>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _ctrl =
|
||||||
|
AnimationController(vsync: this, value: 1);
|
||||||
|
double _from = 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(SpringBump oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.value <= oldWidget.value) return;
|
||||||
|
final motion = PyramidTheme.of(context).motion;
|
||||||
|
if (motion < 0.05) return;
|
||||||
|
// Aus dem Nichts (0 → 1) größer aufploppen als beim Hochzählen.
|
||||||
|
_from = oldWidget.value <= 0 ? 0.3 : 0.7;
|
||||||
|
_ctrl.duration = Duration(milliseconds: (650 * motion).round());
|
||||||
|
_ctrl.forward(from: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _ctrl,
|
||||||
|
child: widget.child,
|
||||||
|
builder: (context, child) => Transform.scale(
|
||||||
|
scale: _from + (1 - _from) * Curves.elasticOut.transform(_ctrl.value),
|
||||||
|
alignment: widget.alignment,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
import 'package:pyramid/layout/mobile_page_stack.dart';
|
||||||
|
import 'package:pyramid/widgets/spring_press.dart';
|
||||||
|
|
||||||
|
/// Handy-Übergang Chatliste ↔ Chat und die federnden Einblendungen.
|
||||||
|
void main() {
|
||||||
|
Widget host({required Widget? front, double motion = 1}) {
|
||||||
|
return PyramidTheme(
|
||||||
|
motion: motion,
|
||||||
|
child: Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: MobilePageStack(
|
||||||
|
base: const _Counter(key: ValueKey('liste')),
|
||||||
|
front: front,
|
||||||
|
background: const Color(0xFF101010),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('Chatliste behält ihren Zustand, Chat gleitet herein und hinaus',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(host(front: null));
|
||||||
|
await tester.tap(find.byKey(const ValueKey('liste')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Zähler 1'), findsOneWidget);
|
||||||
|
|
||||||
|
// Chat öffnen: gleitet von rechts herein.
|
||||||
|
await tester.pumpWidget(host(front: const Text('Chat')));
|
||||||
|
await tester.pump(const Duration(milliseconds: 40));
|
||||||
|
final midX = tester.getTopLeft(find.text('Chat')).dx;
|
||||||
|
expect(midX, greaterThan(0), reason: 'Chat sollte noch hereingleiten');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.getTopLeft(find.text('Chat')).dx, 0);
|
||||||
|
// Verdeckte Liste ist nicht mehr bedienbar, aber noch aufgebaut.
|
||||||
|
expect(find.text('Zähler 1', skipOffstage: false), findsOneWidget);
|
||||||
|
|
||||||
|
// Zurück: Chat gleitet hinaus und wird danach entfernt.
|
||||||
|
await tester.pumpWidget(host(front: null));
|
||||||
|
await tester.pump(const Duration(milliseconds: 40));
|
||||||
|
expect(find.text('Chat'), findsOneWidget, reason: 'noch beim Hinausgleiten');
|
||||||
|
// Schon während des Hinausgleitens ist die Liste wieder bedienbar.
|
||||||
|
await tester.tapAt(const Offset(20, 300));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Zähler 2'), findsOneWidget);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Chat'), findsNothing);
|
||||||
|
// Zustand der Liste ist erhalten geblieben.
|
||||||
|
await tester.tap(find.byKey(const ValueKey('liste')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Zähler 3'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Animationen 0 %: sofortiger Wechsel ohne Gleiten', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(host(front: null, motion: 0));
|
||||||
|
await tester.pumpWidget(host(front: const Text('Chat'), motion: 0));
|
||||||
|
await tester.pump();
|
||||||
|
expect(tester.getTopLeft(find.text('Chat')).dx, 0);
|
||||||
|
await tester.pumpWidget(host(front: null, motion: 0));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Chat'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('PopIn spielt nur, wenn verlangt, und endet unverändert', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
Widget pop(bool play) => PyramidTheme(
|
||||||
|
child: Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: Center(
|
||||||
|
child: PopIn(play: play, child: const Text('Neu')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(pop(true));
|
||||||
|
await tester.pump(const Duration(milliseconds: 30));
|
||||||
|
final opacity = tester.widget<Opacity>(
|
||||||
|
find.ancestor(of: find.text('Neu'), matching: find.byType(Opacity)),
|
||||||
|
);
|
||||||
|
expect(opacity.opacity, lessThan(1));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Opacity>(
|
||||||
|
find.ancestor(of: find.text('Neu'), matching: find.byType(Opacity)),
|
||||||
|
)
|
||||||
|
.opacity,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.pumpWidget(pop(false));
|
||||||
|
expect(
|
||||||
|
find.ancestor(of: find.text('Neu'), matching: find.byType(Opacity)),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('SpringBump hüpft nur beim Hochzählen', (tester) async {
|
||||||
|
Widget bump(int v) => PyramidTheme(
|
||||||
|
child: Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: Center(child: SpringBump(value: v, child: Text('$v'))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
double scale() => tester
|
||||||
|
.widget<Transform>(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.byType(Text),
|
||||||
|
matching: find.byType(Transform),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.transform
|
||||||
|
.storage[0]; // x-Skalierung (z bleibt bei Transform.scale immer 1)
|
||||||
|
|
||||||
|
await tester.pumpWidget(bump(1));
|
||||||
|
expect(scale(), 1);
|
||||||
|
await tester.pumpWidget(bump(2));
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(scale(), lessThan(1), reason: 'Hochzählen soll hüpfen');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(scale(), closeTo(1, 0.001));
|
||||||
|
await tester.pumpWidget(bump(0));
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(scale(), closeTo(1, 0.001), reason: 'Herunterzählen hüpft nicht');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Counter extends StatefulWidget {
|
||||||
|
const _Counter({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_Counter> createState() => _CounterState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CounterState extends State<_Counter> {
|
||||||
|
int _n = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => setState(() => _n++),
|
||||||
|
child: Center(child: Text('Zähler $_n')),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user