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:
co-authored by
Claude Opus 5.5
parent
7063ebfceb
commit
223c9448f6
@@ -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> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user