feat: schwebende Eingabeleiste - Chat laeuft darunter weiter (Milchglas), Flaeche faengt Beruehrungen ab

Bernds Wunsch 2026-10-07: Die Nachrichtenliste reicht jetzt bis ganz unten;
Tipp-Anzeige und Eingabefeld schweben darueber. Um das Feld herum ist die
Flaeche klar (Chat sichtbar), aber beruehrungsdicht - dort reagiert nur die
Eingabe. Hinter dem Feld selbst Milchglas (Unschaerfe + 78 % Flaeche), Rahmen
und Fokus-Ring aussen. Die Liste bekommt unten genau die gemessene Hoehe der
Leiste als Abstand (MeasureSize; waechst mit Antwort-Leiste/mehrzeiligem
Text), damit die neueste Nachricht ganz ueber die Leiste scrollt; der
Nach-unten-Knopf sitzt darueber.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-07 21:26:33 +02:00
co-authored by Claude Opus 5.5
parent 7063ebfceb
commit 223c9448f6
6 changed files with 122 additions and 11 deletions
+13 -1
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui;
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -331,8 +332,10 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
// block in the rooms list (~89px on the user's display). Grows for // block in the rooms list (~89px on the user's display). Grows for
// multiline. // multiline.
constraints: const BoxConstraints(minHeight: 53), constraints: const BoxConstraints(minHeight: 53),
// Rahmen + Fokus-Ring außen (würden vom Zuschnitt sonst
// abgeschnitten), innen Milchglas: der Chat läuft unter dem
// schwebenden Feld durch und scheint unscharf durch.
decoration: BoxDecoration( decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rBase + 4), borderRadius: BorderRadius.circular(pt.rBase + 4),
border: Border.all( border: Border.all(
color: _focus.hasFocus ? pt.accent : pt.border, color: _focus.hasFocus ? pt.accent : pt.border,
@@ -341,6 +344,12 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)] ? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)]
: null, : null,
), ),
child: ClipRRect(
borderRadius: BorderRadius.circular(pt.rBase + 3),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: ColoredBox(
color: pt.bg2.withValues(alpha: 0.78),
child: Focus( child: Focus(
onFocusChange: (_) => setState(() {}), onFocusChange: (_) => setState(() {}),
child: Row( child: Row(
@@ -436,6 +445,9 @@ class _ChatComposerState extends ConsumerState<ChatComposer> {
), ),
), ),
), ),
),
),
),
], ],
), ),
); );
+1
View File
@@ -19,6 +19,7 @@ import 'package:pyramid/features/chat/chat_provider.dart';
import 'package:pyramid/features/chat/message_group.dart'; 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/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/core/notification_service.dart'; import 'package:pyramid/core/notification_service.dart';
@@ -19,6 +19,10 @@ class _MessageList extends StatelessWidget {
final String? highlightEventId; final String? highlightEventId;
final bool highlightOn; final bool highlightOn;
/// Höhe der darüber schwebenden Eingabeleiste: so viel Platz bleibt unten
/// frei, damit die neueste Nachricht ganz über die Leiste scrollen kann.
final double bottomInset;
const _MessageList({ const _MessageList({
required this.timeline, required this.timeline,
required this.scrollCtrl, required this.scrollCtrl,
@@ -35,6 +39,7 @@ class _MessageList extends StatelessWidget {
this.jumpKey, this.jumpKey,
this.highlightEventId, this.highlightEventId,
this.highlightOn = false, this.highlightOn = false,
this.bottomInset = 0,
}); });
@override @override
@@ -241,7 +246,7 @@ class _MessageList extends StatelessWidget {
controller: scrollCtrl, controller: scrollCtrl,
reverse: true, reverse: true,
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 8), padding: EdgeInsets.only(bottom: 8 + bottomInset),
scrollCacheExtent: const ScrollCacheExtent.pixels(2500), scrollCacheExtent: const ScrollCacheExtent.pixels(2500),
itemCount: itemKeys.length, itemCount: itemKeys.length,
findChildIndexCallback: (Key key) { findChildIndexCallback: (Key key) {
+22
View File
@@ -129,3 +129,25 @@ class NoChatSelected extends StatelessWidget {
); );
} }
} }
/// Fläche der schwebenden Eingabeleiste: um das Eingabefeld herum völlig
/// klar – der Chat ist dort normal zu sehen –, fängt aber alle Berührungen
/// ab (ColoredBox ist auch transparent „opak“ für Treffer), damit dort nur
/// die Eingabe reagiert und nicht der Chat dahinter. Die Unschärfe sitzt nur
/// hinter dem Eingabefeld selbst (chat_composer.dart).
class _FloatingComposerArea extends StatelessWidget {
final PyramidTheme pt;
final Widget child;
const _FloatingComposerArea({required this.pt, required this.child});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.transparent,
child: Padding(
padding: const EdgeInsets.only(top: 6),
child: child,
),
);
}
}
+40 -6
View File
@@ -31,6 +31,10 @@ class _ChatViewState extends ConsumerState<ChatView> {
// 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();
// Höhe der schwebenden Eingabeleiste (gemessen, wächst mit Antwort-Leiste
// oder mehrzeiligem Text).
double _composerHeight = 72;
String? _jumpEventId; String? _jumpEventId;
String? _highlightEventId; String? _highlightEventId;
bool _highlightOn = false; bool _highlightOn = false;
@@ -445,13 +449,14 @@ class _ChatViewState extends ConsumerState<ChatView> {
jumpKey: _jumpKey, jumpKey: _jumpKey,
highlightEventId: _highlightEventId, highlightEventId: _highlightEventId,
highlightOn: _highlightOn, highlightOn: _highlightOn,
bottomInset: room != null ? _composerHeight : 0,
); );
}, },
), ),
if (_showScrollDown) if (_showScrollDown)
Positioned( Positioned(
right: 16, right: 16,
bottom: 12, bottom: (room != null ? _composerHeight : 0) + 12,
child: AnimatedOpacity( child: AnimatedOpacity(
opacity: _showScrollDown ? 1 : 0, opacity: _showScrollDown ? 1 : 0,
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
@@ -481,15 +486,44 @@ class _ChatViewState extends ConsumerState<ChatView> {
), ),
), ),
), ),
], // Schwebende Eingabeleiste (Bernds Wunsch 2026-10-07):
), // der Chat läuft optisch darunter weiter
), // (durchscheinend + Unschärfe), die ganze Fläche
if (room != null) _TypingIndicator(room: room, pt: pt), // fängt aber Berührungen ab – dort reagiert nur
// die Eingabe, nicht der Chat dahinter.
if (room != null) if (room != null)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: MeasureSize(
onChange: (size) {
if (mounted &&
(size.height - _composerHeight).abs() >
0.5) {
setState(
() => _composerHeight = size.height);
}
},
child: _FloatingComposerArea(
pt: pt,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_TypingIndicator(room: room, pt: pt),
ChatComposer( ChatComposer(
room: room, room: room,
replyTo: _replyTo, replyTo: _replyTo,
onClearReply: () => setState(() => _replyTo = null), onClearReply: () =>
setState(() => _replyTo = null),
),
],
),
),
),
),
],
),
), ),
], ],
), ),
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// Meldet die gezeichnete Größe des Kindes, sobald sie sich ändert (z. B.
/// die Höhe der schwebenden Eingabeleiste, die mit Antwort-Leiste oder
/// mehrzeiligem Text wächst). Die Meldung kommt nach dem Layout, damit der
/// Empfänger gefahrlos setState aufrufen kann.
class MeasureSize extends SingleChildRenderObjectWidget {
const MeasureSize({super.key, required this.onChange, super.child});
final ValueChanged<Size> onChange;
@override
RenderObject createRenderObject(BuildContext context) =>
_MeasureSizeRender(onChange);
@override
void updateRenderObject(BuildContext context, RenderObject renderObject) {
(renderObject as _MeasureSizeRender).onChange = onChange;
}
}
class _MeasureSizeRender extends RenderProxyBox {
_MeasureSizeRender(this.onChange);
ValueChanged<Size> onChange;
Size? _last;
@override
void performLayout() {
super.performLayout();
final size = this.size;
if (size == _last) return;
_last = size;
WidgetsBinding.instance.addPostFrameCallback((_) => onChange(size));
}
}