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
@@ -35,6 +35,10 @@ class ChatComposer extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
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();
|
||||
late final FocusNode _focus;
|
||||
bool _canSend = false;
|
||||
@@ -47,6 +51,12 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_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);
|
||||
}
|
||||
|
||||
@@ -121,6 +131,8 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final text = _ctrl.text;
|
||||
if (text.trim().isNotEmpty) _drafts[widget.room.id] = text;
|
||||
_typingTimer?.cancel();
|
||||
if (_isTyping) widget.room.setTyping(false).catchError((_) {});
|
||||
_gifOverlay?.remove();
|
||||
@@ -315,12 +327,28 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.replyTo != null)
|
||||
_ReplyBar(
|
||||
event: widget.replyTo!,
|
||||
onClear: widget.onClearReply ?? () {},
|
||||
pt: pt,
|
||||
// Antwort-Leiste federt auf und klappt beim Schließen weich zu.
|
||||
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!,
|
||||
onClear: widget.onClearReply ?? () {},
|
||||
pt: pt,
|
||||
),
|
||||
)
|
||||
: const SizedBox(key: ValueKey('reply:none'), width: double.infinity),
|
||||
),
|
||||
Padding(
|
||||
// Bottom inset matches the profile block in the rooms list (8px) so
|
||||
// the composer sits flush with it.
|
||||
@@ -574,10 +602,18 @@ class _SendButtonState extends State<_SendButton> {
|
||||
: Matrix4.identity(),
|
||||
transformAlignment: Alignment.center,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.send_rounded,
|
||||
size: widget.size * 0.45,
|
||||
color: active ? pt.accentFg : pt.fgDim,
|
||||
// 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(
|
||||
Icons.send_rounded,
|
||||
size: widget.size * 0.45,
|
||||
color: active ? pt.accentFg : pt.fgDim,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -627,9 +663,20 @@ class _ReplyBar extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
// Handy: größeres X mit fingergerechter Trefferfläche.
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user