Bernds Test 2026-10-08 (Streamen ging weder am PC noch am Handy, Ton kam nicht an, Trenner blieb manchmal stehen) – mit neuem Live-Anruftest (zwei Testkonten im selben Prozess) nachgestellt und behoben: - Bildschirm teilen: Die Stromzuordnung rief ein SDK-getFeeds() auf, das es nicht gibt; der Fehler wurde geschluckt → Übertragungen wurden nie angezeigt und beim Sender nie als aktiv erkannt. Jetzt typisierte Zugriffe (remote/localScreenSharingStream, 'stopped' beachtet). - Verbindungsaufbau: matrix-SDK verwirft Kandidaten, die vor der fertig verarbeiteten Einladung bzw. während des Annehmens eintreffen; unter Windows wirft getRemoteDescription zudem statt null zu liefern. Pyramid puffert alle Kandidaten der Gegenseite und reicht sie direkt an die Verbindung nach. Test: vorher nie verbunden, jetzt ~0,5 s (angerufen) bzw. ~2,7 s (anrufend), Ton beide Richtungen, Teilen beide Richtungen. - SDK-Aufrufe in eigener Zone: harmlose Plugin-Fehler (Lautsprecher unter Windows) werden protokolliert statt durchzuschlagen; Ergebnisse/Fehler per Completer über die Zonengrenze (sonst ewiges Warten). - Sprech-Ring auch bei 1:1-Anrufen (Pegel aus WebRTC-Statistiken) – zeigt, ob das eigene Mikrofon Ton liefert und ob die Gegenseite ankommt; großes Bild springt nicht mehr bei jedem Sprechwechsel. - „Neue Nachrichten“ mitten in einer Gruppe (mehrere Nachrichten desselben Absenders) blieb bis zum erneuten Öffnen stehen: Inline-Trenner jetzt nur mit Ungelesenem, blendet aus und trägt den Sprungschlüssel. Live-Test. - „Anruf läuft“-Leiste nicht schon beim Klingeln. Co-Authored-By: Claude Opus 5.5 <[email protected]>
262 lines
8.5 KiB
Dart
262 lines
8.5 KiB
Dart
part of '../message_group.dart';
|
||
|
||
// Sprechblasen-Darstellung (Einstellung „Nachrichten: Sprechblasen“, auf dem
|
||
// Handy Standard – Bernds Entscheidung 2026-10-07). Eigene Nachrichten rechts
|
||
// in Akzentfarbe, andere links; in Gruppenräumen mit Name + Avatar, in
|
||
// Direktnachrichten ohne. Bilder/Videos ohne Blase, dafür in voller Größe.
|
||
// Inhalt (Text, Antwort-Zitat, Medien), Reaktionen, Auswahl, Hover-Aktionen
|
||
// und Wischen-zum-Antworten kommen aus denselben Bausteinen wie die Liste.
|
||
|
||
const _kBubbleMaxWidthFactor = 0.82;
|
||
const _kBubbleAvatarSize = 30.0;
|
||
const _kBubbleAvatarGap = 8.0;
|
||
|
||
extension _BubbleBuilder on _MessageGroupState {
|
||
Widget _buildBubbles(BuildContext context, PyramidTheme pt) {
|
||
final firstEvent = widget.events.first;
|
||
final isOwn = firstEvent.senderId == widget.currentUserId;
|
||
final isDm = widget.room?.isDirectChat ?? false;
|
||
// Name + Avatar nur bei fremden Nachrichten in Gruppenräumen.
|
||
final showSender = !isOwn && !isDm;
|
||
final sender = firstEvent.senderFromMemoryOrFallback;
|
||
final name = sender.calcDisplayname();
|
||
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
|
||
final color = _colorFromId(firstEvent.senderId);
|
||
|
||
return LayoutBuilder(builder: (context, constraints) {
|
||
final rowWidth = constraints.maxWidth - 24; // 12 px Rand links/rechts
|
||
final senderSpace =
|
||
showSender ? _kBubbleAvatarSize + _kBubbleAvatarGap : 0.0;
|
||
final maxBubbleWidth =
|
||
((rowWidth - senderSpace) * _kBubbleMaxWidthFactor).clamp(120.0, 560.0);
|
||
|
||
final rows = <Widget>[];
|
||
for (var j = 0; j < widget.events.length; j++) {
|
||
final event = widget.events[j];
|
||
final isFirst = j == 0;
|
||
final isLast = j == widget.events.length - 1;
|
||
|
||
final bubbleColumn = Column(
|
||
crossAxisAlignment:
|
||
isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_Bubble(
|
||
event: event,
|
||
pt: pt,
|
||
isOwn: isOwn,
|
||
isFirst: isFirst,
|
||
isLast: isLast,
|
||
senderName: showSender && isFirst ? name : null,
|
||
senderColor: color,
|
||
maxWidth: maxBubbleWidth,
|
||
timeline: widget.timeline,
|
||
),
|
||
if (widget.timeline != null)
|
||
_ReactionsRow(
|
||
event: event,
|
||
timeline: widget.timeline!,
|
||
room: widget.room,
|
||
currentUserId: widget.currentUserId,
|
||
pt: pt,
|
||
),
|
||
],
|
||
);
|
||
|
||
final line = Row(
|
||
mainAxisAlignment:
|
||
isOwn ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (showSender) ...[
|
||
SizedBox(
|
||
width: _kBubbleAvatarSize,
|
||
child: isFirst
|
||
? _Avatar(
|
||
initial: initial,
|
||
color: color,
|
||
pt: pt,
|
||
size: _kBubbleAvatarSize,
|
||
avatarUri: sender.avatarUrl,
|
||
)
|
||
: null,
|
||
),
|
||
const SizedBox(width: _kBubbleAvatarGap),
|
||
],
|
||
Flexible(child: bubbleColumn),
|
||
],
|
||
);
|
||
|
||
rows.add(_selectableRow(
|
||
event,
|
||
pt,
|
||
Padding(
|
||
padding: EdgeInsets.only(
|
||
top: isFirst ? pt.msgGap / 2 : 3,
|
||
left: 12,
|
||
right: 12,
|
||
),
|
||
child: line,
|
||
),
|
||
));
|
||
if (widget.readEventIds.contains(event.eventId)) {
|
||
rows.add(_ReadMarker(pt: pt));
|
||
}
|
||
if (widget.fullyReadId == event.eventId && !isLast) {
|
||
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
|
||
}
|
||
}
|
||
|
||
if (widget.showStatusIndicator &&
|
||
isOwn &&
|
||
widget.room != null &&
|
||
!widget.readEventIds.contains(widget.events.last.eventId)) {
|
||
rows.add(Padding(
|
||
padding: const EdgeInsets.only(right: 16, bottom: 1),
|
||
child: Align(
|
||
alignment: Alignment.centerRight,
|
||
child: _MessageStatus(lastEvent: widget.events.last, pt: pt),
|
||
),
|
||
));
|
||
}
|
||
rows.add(const SizedBox(height: 2));
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: rows,
|
||
);
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Eine einzelne Sprechblase (bzw. bei Bildern/Videos: das Medium ohne Blase).
|
||
class _Bubble extends StatelessWidget {
|
||
final Event event;
|
||
final PyramidTheme pt;
|
||
final bool isOwn;
|
||
final bool isFirst;
|
||
final bool isLast;
|
||
final String? senderName;
|
||
final Color senderColor;
|
||
final double maxWidth;
|
||
final Timeline? timeline;
|
||
|
||
const _Bubble({
|
||
required this.event,
|
||
required this.pt,
|
||
required this.isOwn,
|
||
required this.isFirst,
|
||
required this.isLast,
|
||
required this.senderName,
|
||
required this.senderColor,
|
||
required this.maxWidth,
|
||
this.timeline,
|
||
});
|
||
|
||
Event get _displayEvent =>
|
||
timeline != null ? event.getDisplayEvent(timeline!) : event;
|
||
|
||
bool get _isVisualMedia {
|
||
final e = _displayEvent;
|
||
if (e.type == EventTypes.Sticker) return true;
|
||
return e.type == EventTypes.Message &&
|
||
(e.messageType == MessageTypes.Image ||
|
||
e.messageType == MessageTypes.Video);
|
||
}
|
||
|
||
/// Eigene Blase: Akzentfarbe, mit der Fläche verrechnet (bleibt lesbar).
|
||
static Color ownColor(PyramidTheme pt) => Color.alphaBlend(
|
||
pt.accent.withValues(alpha: pt.isDark ? 0.30 : 0.24),
|
||
pt.bg1,
|
||
);
|
||
|
||
BorderRadius get _radius {
|
||
const big = Radius.circular(18);
|
||
const small = Radius.circular(6);
|
||
// Aufeinanderfolgende Blasen „kleben“ auf der Absenderseite zusammen.
|
||
final topSide = isFirst ? big : small;
|
||
final bottomSide = isLast ? big : small;
|
||
return isOwn
|
||
? BorderRadius.only(
|
||
topLeft: big, bottomLeft: big, topRight: topSide, bottomRight: bottomSide)
|
||
: BorderRadius.only(
|
||
topRight: big, bottomRight: big, topLeft: topSide, bottomLeft: bottomSide);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final time = Text(
|
||
_formatMessageTime(event.originServerTs),
|
||
style: TextStyle(color: pt.fgDim, fontSize: 11),
|
||
);
|
||
final failed = event.status.isError
|
||
? _SendFailedRow(event: event, pt: pt)
|
||
: null;
|
||
|
||
if (_isVisualMedia) {
|
||
// Ohne Blase: Bilder/Videos sollen so groß wie möglich sein.
|
||
final box = _reservedImageSize(
|
||
_displayEvent,
|
||
maxW: maxWidth.clamp(80.0, 420.0),
|
||
maxH: inlineMediaMaxHeight(context),
|
||
);
|
||
return Column(
|
||
crossAxisAlignment:
|
||
isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (senderName != null) _senderLabel(),
|
||
SizedBox(
|
||
width: box?.width ?? maxWidth,
|
||
child: _MessageContent(event: event, pt: pt, timeline: timeline),
|
||
),
|
||
?failed,
|
||
Padding(padding: const EdgeInsets.only(top: 2), child: time),
|
||
],
|
||
);
|
||
}
|
||
|
||
return ConstrainedBox(
|
||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||
child: Container(
|
||
padding: const EdgeInsets.fromLTRB(12, 7, 12, 6),
|
||
decoration: BoxDecoration(
|
||
color: isOwn ? ownColor(pt) : pt.bg3,
|
||
borderRadius: _radius,
|
||
),
|
||
// Außen „end“: Inhalt (breitestes Kind) füllt die Blase, die Uhrzeit
|
||
// sitzt unten rechts – ohne die Blase auf volle Breite zu ziehen.
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (senderName != null) _senderLabel(),
|
||
_MessageContent(event: event, pt: pt, timeline: timeline),
|
||
?failed,
|
||
],
|
||
),
|
||
const SizedBox(height: 2),
|
||
time,
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _senderLabel() => Padding(
|
||
padding: const EdgeInsets.only(bottom: 2),
|
||
child: Text(
|
||
senderName!,
|
||
style: TextStyle(
|
||
color: senderColor,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
);
|
||
}
|